diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 42f610e08..33b49c5d3 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -2,6 +2,7 @@ import fs from "node:fs"; import batchflow from "batchflow"; import _ from "lodash"; import errs from "../lib/error.js"; +import { isValid, unsafeValueError } from "../lib/nginx-values.js"; import { isMysql, isPostgres } from "../lib/config.js"; import utils from "../lib/utils.js"; import db from "../db.js"; @@ -500,6 +501,11 @@ const internalAccessList = { .sequential() .each((_i, item, next) => { if (item.password?.length) { + // One "user:hash" line each; a ":" or line break in the name would corrupt the file + if (!isValid("accessUsername", item.username)) { + reject(unsafeValueError(`Access List #${list.id}: user name is not allowed`)); + return; + } logger.info(`Adding: ${item.username}`); utils diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 4efd67f6f..06180e011 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -4,6 +4,7 @@ import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import _ from "lodash"; import errs from "../lib/error.js"; +import { assertAccessListRenderable, assertHostRenderable, isTokenSafe, unsafeValueError } from "../lib/nginx-values.js"; import utils from "../lib/utils.js"; import { debug, nginx as logger } from "../logger.js"; import accessListModel from "../models/access_list.js"; @@ -40,9 +41,30 @@ const internalNginx = { return internalNginx.deleteConfig(host_type, host, true); }) .then(() => { - return internalNginx.generateConfig(host_type, host); + return internalNginx.generateConfig(host_type, host).then( + () => true, + (err) => { + if (!err.unsafeNginxValue) { + throw err; + } + // Nothing was written. Report it on the host like a failed nginx test. + logger.error(err.message); + combined_meta = _.assign({}, host.meta, { + nginx_online: false, + nginx_err: err.message, + }); + return model + .query() + .where("id", host.id) + .patch({ meta: combined_meta }) + .then(() => false); + }, + ); }) - .then(() => { + .then((generated) => { + if (!generated) { + return; + } // Test nginx again and update meta with result return internalNginx .test() @@ -182,6 +204,10 @@ const internalNginx = { .first(); } if (locationAccessList) { + assertAccessListRenderable( + `Proxy Host #${host.id} location ${locationCopy.path}`, + locationAccessList, + ); locationCopy.access_list = locationAccessList; } else if ( locationCopy.access_list_id > 0 && @@ -227,6 +253,9 @@ const internalNginx = { await internalNginx.loadRenderRelations(nice_host_type, host); + // Values are written into the config as-is; refuse any that could add directives + assertHostRenderable(nice_host_type, host); + debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2)); const renderEngine = utils.getRenderEngine(); @@ -374,6 +403,15 @@ const internalNginx = { debug(logger, "Generating LetsEncrypt Request Config:", certificate); const renderEngine = utils.getRenderEngine(); + const unsafe = (certificate.domain_names || []).find((name) => !isTokenSafe(name)); + if (typeof unsafe !== "undefined") { + return Promise.reject( + unsafeValueError( + `Certificate #${certificate.id}: domain name ${JSON.stringify(unsafe)} is not allowed`, + ), + ); + } + return new Promise((resolve, reject) => { let template = null; const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`; diff --git a/backend/lib/nginx-values.js b/backend/lib/nginx-values.js new file mode 100644 index 000000000..0f5382623 --- /dev/null +++ b/backend/lib/nginx-values.js @@ -0,0 +1,134 @@ +import net from "node:net"; +import errs from "./error.js"; + +/** + * Rules for user-supplied values that the templates write into nginx config as-is. + * + * Anything that ends or changes an nginx token (whitespace, ";", "{", "}", quotes, "#" which + * starts a comment, and "\" which escapes the next character) would let the value add its own + * directives, which only admins may do (advanced_config). The API schema rejects these values + * with the same patterns (a test keeps the two in step). generateConfig() checks stored values + * again, so rows saved before these rules can't add directives either. That check is only about + * nginx structure, not format: a stored value in a format the API no longer accepts, but which + * can't add directives, still renders. + * + * Patterns are strings so they can be compared with the JSON schema; compile them with the "u" + * flag, as ajv does. + */ + +// One character that can't end or change an unquoted nginx token +const TOKEN_CHAR = String.raw`[^\s\p{Cc};{}"'#\\]`; +// Host part of a forward host: also no "$" (no variables in the upstream address) and no "/" +const HOST_CHARS = String.raw`[^\s\p{Cc};{}"'#\\$/]+`; +// The existing domain name rule ("a.b.c", no "*", no empty labels), written so it can't +// backtrack exponentially +const DOMAIN = String.raw`[^.*]+(?:\.[^.*]+)*\.?[^.]`; + +export const patterns = { + // server_name entries. The old character blacklist plus whitespace and control characters. + domainName: "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$", + // proxy_host forward_host and location forward_host: a host, optionally followed by a path + // (locations split "host/path" at render time) + forwardHost: String.raw`^${HOST_CHARS}(?:/${TOKEN_CHAR}*)?$`, + // location forward_path, appended straight after the port + forwardPath: String.raw`^${TOKEN_CHAR}*$`, + // location path: an optional modifier, then one token, either unquoted (a "\" must escape a + // visible character, so it can't swallow the space before "{") or double quoted. A modifier + // on its own isn't a location. + locationPath: String.raw`^(?!(?:=|~\*?|\^~)$)(?:(?:=|~\*?|\^~) +)?(?:"(?:[^"\\\p{Cc}]|\\[^\p{Cc}])+"|(?:[^\s\p{Cc};{}"'#\\]|\\[^\s\p{Cc}])+)$`, + // redirection forward_domain_name, e.g. "example.com:8443/path" + redirectDomain: String.raw`^(?=${TOKEN_CHAR}+$)${DOMAIN}$`, + // stream forwarding_host when it is a domain name (IPs are matched separately) + streamDomain: String.raw`^(?=${HOST_CHARS}$)${DOMAIN}$`, + // access list client address: IPv6 with an optional prefix. No zone id ("%eth0"): nginx's + // allow/deny rejects them, and the old "%.+" let anything through, including "; allow all" + clientIpv6: String.raw`^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$`, + // htpasswd user name: a ":" or a line break would corrupt the file + accessUsername: String.raw`^[^:\p{Cc}]+$`, +}; + +const re = Object.fromEntries(Object.entries(patterns).map(([k, v]) => [k, new RegExp(v, "u")])); +const unsafeToken = /[\s\p{Cc};{}"'#\\]/u; +const ipv4WithPrefix = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/; + +export const isValid = (name, value) => typeof value === "string" && re[name].test(value); + +export const isValidClientAddress = (value) => + typeof value === "string" && (value === "all" || ipv4WithPrefix.test(value) || re.clientIpv6.test(value)); + +export const isValidStreamHost = (value) => + typeof value === "string" && (net.isIP(value) !== 0 || re.streamDomain.test(value)); + +/** + * Thrown when a stored value can't be written into nginx config safely + * + * @param {String} message + */ +export const unsafeValueError = (message) => { + const err = new errs.ConfigurationError(message); + err.unsafeNginxValue = true; + return err; +}; + +const describe = (value) => JSON.stringify(String(value)).slice(0, 80); + +/** + * @param {String} label e.g. "Proxy Host #3" + * @param {Object} [accessList] + * @throws {ConfigurationError} + */ +export const assertAccessListRenderable = (label, accessList) => { + for (const client of accessList?.clients || []) { + // "address" is rendered unquoted ("allow
;") + if (typeof client.address !== "string" || unsafeToken.test(client.address)) { + throw unsafeValueError(`${label}: access list client address ${describe(client.address)} is not allowed`); + } + } +}; + +// Can be written into config without ending or changing a token +export const isTokenSafe = (value) => typeof value === "string" && value.length > 0 && !unsafeToken.test(value); + +/** + * Checks every user-supplied value the host's template renders. Call before rendering. + * + * @param {String} hostType proxy_host, redirection_host, dead_host, stream or default + * @param {Object} host with access_list loaded, for proxy hosts + * @throws {ConfigurationError} + */ +export const assertHostRenderable = (hostType, host) => { + const label = `${hostType.replace(/_/g, " ")} #${host.id}`; + const check = (ok, what, value) => { + if (!ok) { + throw unsafeValueError(`${label}: ${what} ${describe(value)} is not allowed in nginx config`); + } + }; + + if (hostType !== "stream" && hostType !== "default") { + for (const name of host.domain_names || []) { + check(isTokenSafe(name), "domain name", name); + } + } + + if (hostType === "proxy_host") { + check(isTokenSafe(host.forward_host), "forward host", host.forward_host); + assertAccessListRenderable(label, host.access_list); + for (const loc of host.locations || []) { + // The path is several tokens (modifier, then a quoted or unquoted URI): its rule is the + // structural one + check(isValid("locationPath", loc.path), "location path", loc.path); + check(isTokenSafe(loc.forward_host), "location forward host", loc.forward_host); + if (loc.forward_path) { + check(isTokenSafe(loc.forward_path), "location forward path", loc.forward_path); + } + } + } + + if (hostType === "redirection_host") { + check(isTokenSafe(host.forward_domain_name), "forward domain", host.forward_domain_name); + } + + if (hostType === "stream") { + check(isTokenSafe(host.forwarding_host), "forwarding host", host.forwarding_host); + } +}; diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js index 0597ef635..cc25a4f35 100644 --- a/backend/lib/validator/api.js +++ b/backend/lib/validator/api.js @@ -35,6 +35,13 @@ const apiValidator = async (schema, payload /*, description*/) => { return payload; } + // Keep ajv's wording, which clients may match on, but not the regex it quotes: some are long + // and mean nothing to a user + for (const e of validate.errors || []) { + if (e.keyword === "pattern") { + e.message = "must match pattern"; + } + } const message = ajv.errorsText(validate.errors); const err = new errs.ValidationError(message); err.debug = { validationErrors: validate.errors, payload }; diff --git a/backend/package.json b/backend/package.json index 1e1646333..0a65fbe70 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ "type": "module", "scripts": { "lint": "biome lint", + "test": "node --test test/", "prettier": "biome format --write .", "validate-schema": "node validate-schema.js", "regenerate-config": "node scripts/regenerate-config" diff --git a/backend/schema/common.json b/backend/schema/common.json index 00b06e005..3b12bd143 100644 --- a/backend/schema/common.json +++ b/backend/schema/common.json @@ -84,7 +84,7 @@ "uniqueItems": true, "items": { "type": "string", - "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$" + "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$" }, "example": ["example.com", "www.example.com"] }, @@ -147,7 +147,7 @@ }, { "type": "string", - "pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$" + "pattern": "^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$" }, { "type": "string", @@ -164,7 +164,8 @@ "properties": { "username": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^[^:\\p{Cc}]+$" }, "password": { "type": "string" diff --git a/backend/schema/components/certificate-object.json b/backend/schema/components/certificate-object.json index 80cd92bef..c86a54da7 100644 --- a/backend/schema/components/certificate-object.json +++ b/backend/schema/components/certificate-object.json @@ -31,7 +31,7 @@ "uniqueItems": true, "items": { "type": "string", - "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$" + "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$" }, "example": ["example.com", "www.example.com"] }, diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 90e6e23bb..dcef18cfb 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -45,6 +45,7 @@ "type": "string", "minLength": 1, "maxLength": 255, + "pattern": "^[^\\s\\p{Cc};{}\"'#\\\\$/]+(?:/[^\\s\\p{Cc};{}\"'#\\\\]*)?$", "example": "127.0.0.1" }, "forward_port": { @@ -108,7 +109,8 @@ }, "path": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^(?!(?:=|~\\*?|\\^~)$)(?:(?:=|~\\*?|\\^~) +)?(?:\"(?:[^\"\\\\\\p{Cc}]|\\\\[^\\p{Cc}])+\"|(?:[^\\s\\p{Cc};{}\"'#\\\\]|\\\\[^\\s\\p{Cc}])+)$" }, "forward_scheme": { "$ref": "#/properties/forward_scheme" @@ -120,7 +122,8 @@ "$ref": "#/properties/forward_port" }, "forward_path": { - "type": "string" + "type": "string", + "pattern": "^[^\\s\\p{Cc};{}\"'#\\\\]*$" }, "advanced_config": { "type": "string" diff --git a/backend/schema/components/redirection-host-object.json b/backend/schema/components/redirection-host-object.json index 58169720b..199053019 100644 --- a/backend/schema/components/redirection-host-object.json +++ b/backend/schema/components/redirection-host-object.json @@ -57,7 +57,7 @@ "forward_domain_name": { "description": "Domain Name", "type": "string", - "pattern": "^(?:[^.*]+\\.?)+[^.]$", + "pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$", "example": "jc21.com" }, "preserve_path": { diff --git a/backend/schema/components/stream-object.json b/backend/schema/components/stream-object.json index 3a1cd5210..3b8cd4f55 100644 --- a/backend/schema/components/stream-object.json +++ b/backend/schema/components/stream-object.json @@ -39,7 +39,7 @@ { "description": "Domain Name", "type": "string", - "pattern": "^(?:[^.*]+\\.?)+[^.]$", + "pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\$/]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$", "example": "example.com" }, { diff --git a/backend/test/nginx-values.test.js b/backend/test/nginx-values.test.js new file mode 100644 index 000000000..a70a986f3 --- /dev/null +++ b/backend/test/nginx-values.test.js @@ -0,0 +1,550 @@ +/** + * Regression tests: values a non-admin can set must never add nginx directives. + * + * Templates write these values into config as-is, so each one is checked three ways: + * - the API rejects injection attempts (real request schemas, real validator) + * - accepted values, rendered through the real templates, produce exactly the same nginx + * statement structure as a plain value (parsed with nginx's tokenizer rules) + * - generateConfig's render-time check refuses unsafe values already stored in the database + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { describe, it } from "node:test"; +import { + assertHostRenderable, + isValid, + isValidClientAddress, + isValidStreamHost, + patterns, +} from "../lib/nginx-values.js"; +import utils from "../lib/utils.js"; +import apiValidator from "../lib/validator/api.js"; +import { getCompiledSchema, getValidationSchema } from "../schema/index.js"; + +const TEMPLATES = new URL("../templates/", import.meta.url); + +// --------------------------------------------------------------------------------------------- +// A tokenizer following ngx_conf_read_token: whitespace separates, ";" ends a statement, "{" +// opens a block, "}" closes one, "#" at the start of a token comments to end of line, quotes +// group, "\" escapes the next character, and "${" doesn't open a block. +// --------------------------------------------------------------------------------------------- +const parseNginx = (text) => { + const root = []; + const stack = [root]; + let args = []; + let i = 0; + const n = text.length; + const isSpace = (c) => c === " " || c === "\t" || c === "\r" || c === "\n"; + + while (i < n) { + const c = text[i]; + if (isSpace(c)) { + i++; + } else if (c === "#") { + while (i < n && text[i] !== "\n") i++; + } else if (c === ";") { + if (!args.length) throw new Error(`unexpected ";" at ${i}`); + stack.at(-1).push({ name: args[0], args: args.slice(1) }); + args = []; + i++; + } else if (c === "{") { + if (!args.length) throw new Error(`unexpected "{" at ${i}`); + const block = { name: args[0], args: args.slice(1), block: [] }; + stack.at(-1).push(block); + stack.push(block.block); + args = []; + i++; + } else if (c === "}") { + if (args.length) throw new Error(`unexpected "}" at ${i}`); + if (stack.length === 1) throw new Error(`unbalanced "}" at ${i}`); + stack.pop(); + i++; + } else if (c === '"' || c === "'") { + let tok = c; + i++; + while (i < n && text[i] !== c) { + if (text[i] === "\\" && i + 1 < n) { + tok += text[i] + text[i + 1]; + i += 2; + } else { + tok += text[i++]; + } + } + if (i >= n) throw new Error("unterminated quoted string"); + tok += c; + i++; + if (i < n && !isSpace(text[i]) && text[i] !== ";" && text[i] !== "{") { + throw new Error(`unexpected "${text[i]}" after quoted string`); + } + args.push(tok); + } else { + let tok = ""; + while (i < n) { + const d = text[i]; + if (d === "\\" && i + 1 < n) { + tok += d + text[i + 1]; + i += 2; + continue; + } + if (d === "$" && text[i + 1] === "{") { + tok += "${"; + i += 2; + continue; + } + if (isSpace(d) || d === ";" || d === "{") break; + tok += d; + i++; + } + args.push(tok); + } + } + if (args.length) throw new Error("statement without ;"); + if (stack.length !== 1) throw new Error("unclosed block"); + return root; +}; + +// Statement names and argument counts, nested; values themselves don't matter +const shape = (stmts) => + stmts.map((s) => `${s.name}/${s.args.length}${s.block ? `{${shape(s.block).join(",")}}` : ""}`); + +const find = (stmts, name) => { + for (const s of stmts) { + if (s.name === name) return s; + if (s.block) { + const f = find(s.block, name); + if (f) return f; + } + } + return null; +}; + +const render = (file, ctx) => + utils.getRenderEngine().parseAndRender(fs.readFileSync(new URL(file, TEMPLATES), "utf8"), ctx); + +// Same structure as a plain value, or throws (a value that breaks parsing changes it too) +const sameShapeAs = async (file, makeCtx, plain, value) => { + const base = shape(parseNginx(await render(file, makeCtx(plain)))); + let got; + try { + got = shape(parseNginx(await render(file, makeCtx(value)))); + } catch { + return false; + } + return JSON.stringify(got) === JSON.stringify(base); +}; + +// --------------------------------------------------------------------------------------------- +// Corpora. "injections" add or change directives when rendered unchecked; "rejected" are other +// values the rules refuse (they would break or confuse the config without injecting). +// --------------------------------------------------------------------------------------------- +const cases = { + locationPath: { + valid: [ + "/", + "/api", + "/api/", + "= /exact", + "~ \\.php$", + "~* \\.(jpg|png|gif)$", + "^~ /static/", + '~ "^/v[0-9]{1,2}/"', + '"/with space"', + "/über", + "/a$b", + "/a\\;b", + ], + injections: [ + "/x { return 200; } location /y", + "/x {} include /etc/passwd; location /y", + "/a; } server { listen 8080", + "/a\nreturn 200 x;\nlocation /b", + '"/a" { return 200; } location "/b"', + "/a {", + ], + rejected: ["", " /a", "/a b", "/a#", "/a\\", '"/a', '"/a\\"', "'/a'", "~", "~ /a\tb", "/a}", "/a\u0000"], + }, + forwardHost: { + valid: ["127.0.0.1", "backend", "my_service.internal", "10.0.0.1/path/", "backend/$1", "[::1]", "host:8080"], + injections: [ + "a; return 200; #", + 'a"; return 200; set $x "', + "a:80; } location /x { return 200", + "a\nreturn 200;", + ], + rejected: ["", "a b", "a'", "a{", "a}", "a#b", "a\\b", "$http_host", "a$b", "/path", "a/b c"], + }, + forwardPath: { + valid: ["", "/", "/x", "/x/$1/"], + injections: ["/x; return 200", "/x\nreturn 200"], + rejected: ["/x y", "/x#", "/x{", "/x\\"], + }, + redirectDomain: { + valid: [ + "example.com", + "example.com:8443", + "example.com/path", + "sub.example.co.uk/a?b=c", + "example.com/$1", + "ab", + ], + injections: ["example.com; return 200 x; #", "example.com;\nreturn 200", "a.com { return 200; }"], + rejected: ["a b.com", "a.com#x", 'a.com"', "a.com\\", ".com", "a..b", "a", "a.com."], + }, + streamHost: { + valid: ["example.com", "1.2.3.4", "::1", "backend.internal"], + injections: ["a.com:80; } server { listen 9999", "a.com;\nlisten 1"], + rejected: ["a.com/x", "$host", "a b", "a.com#"], + }, + domainName: { + valid: ["example.com", "*.example.com", "xn--bcher-kva.example", "a-b.example"], + injections: ["a.com\nreturn 200 x", "a.com\n} server {"], + rejected: ["a\tb.com", "a;b", "a{b", "a b"], + }, + clientAddress: { + valid: ["192.168.0.1", "10.0.0.0/8", "all", "::1", "2001:db8::/32", "::ffff:192.168.1.1", "fe80::1"], + injections: ["::1%x; allow all", "::1%x\nallow all", "1.2.3.4; allow all"], + rejected: ["fe80::1%eth0", "s::1", "1.2.3.4/33", "::ffff:1d.1.1.1"], + }, + accessUsername: { + valid: ["admin", "john smith", "ü"], + injections: [], + rejected: ["a:b", "a\nroot:$apr1$x$y", "\u0000"], + }, +}; + +const checkers = { + locationPath: (v) => isValid("locationPath", v), + forwardHost: (v) => isValid("forwardHost", v), + forwardPath: (v) => isValid("forwardPath", v), + redirectDomain: (v) => isValid("redirectDomain", v), + streamHost: isValidStreamHost, + domainName: (v) => isValid("domainName", v), + clientAddress: isValidClientAddress, + accessUsername: (v) => isValid("accessUsername", v), +}; + +describe("nginx value rules", () => { + for (const [name, { valid, injections, rejected }] of Object.entries(cases)) { + it(`${name}: accepts valid values`, () => { + for (const v of valid) assert.ok(checkers[name](v), `should accept ${JSON.stringify(v)}`); + }); + it(`${name}: rejects injections and other unsafe values`, () => { + for (const v of [...injections, ...rejected]) { + assert.ok(!checkers[name](v), `should reject ${JSON.stringify(v)}`); + } + }); + } + + it("redirect domain rule matches the old rule for token-safe values", () => { + const old = /^(?:[^.*]+\.?)+[^.]$/u; + const corpus = [ + "ab", + "a.b", + "a.b.c", + "a..b", + ".a", + "a.", + "a*", + "a.*", + "*a", + "a*b", + "a.b:80/x", + "x", + "ab.", + "a.b.", + ]; + for (const v of corpus) { + assert.equal(isValid("redirectDomain", v), old.test(v), `differs for ${JSON.stringify(v)}`); + } + }); + + it("domain rules don't backtrack exponentially", () => { + const evil = `${"a".repeat(30000)}.`; + const start = process.hrtime.bigint(); + isValid("redirectDomain", evil); + isValidStreamHost(evil); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + assert.ok(ms < 1000, `took ${ms}ms`); + }); +}); + +describe("API request validation", async () => { + await getCompiledSchema(); + + const location = (o) => ({ path: "/api", forward_scheme: "http", forward_host: "backend", forward_port: 80, ...o }); + const proxyHost = (o) => ({ + domain_names: ["example.com"], + forward_scheme: "http", + forward_host: "backend", + forward_port: 80, + ...o, + }); + const redirection = (o) => ({ + domain_names: ["example.com"], + forward_scheme: "http", + forward_http_code: 301, + forward_domain_name: "example.org", + ...o, + }); + const stream = (o) => ({ + incoming_port: 9000, + forwarding_host: "backend", + forwarding_port: 80, + tcp_forwarding: true, + udp_forwarding: false, + ...o, + }); + + // [field, endpoint, method, payload builder] + const endpoints = { + locationPath: [ + ["/nginx/proxy-hosts", "post", (v) => proxyHost({ locations: [location({ path: v })] })], + ["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ path: v })] })], + ], + forwardHost: [ + ["/nginx/proxy-hosts", "post", (v) => proxyHost({ forward_host: v })], + ["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ forward_host: v })], + ["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_host: v })] })], + ], + forwardPath: [["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_path: v })] })]], + redirectDomain: [ + ["/nginx/redirection-hosts", "post", (v) => redirection({ forward_domain_name: v })], + ["/nginx/redirection-hosts/{hostID}", "put", (v) => ({ forward_domain_name: v })], + ], + streamHost: [ + ["/nginx/streams", "post", (v) => stream({ forwarding_host: v })], + ["/nginx/streams/{streamID}", "put", (v) => ({ forwarding_host: v })], + ], + domainName: [ + ["/nginx/proxy-hosts", "post", (v) => proxyHost({ domain_names: [v] })], + ["/nginx/redirection-hosts", "post", (v) => redirection({ domain_names: [v] })], + ["/nginx/dead-hosts", "post", (v) => ({ domain_names: [v] })], + ["/nginx/certificates", "post", (v) => ({ provider: "letsencrypt", domain_names: [v] })], + ], + clientAddress: [ + ["/nginx/access-lists", "post", (v) => ({ name: "x", clients: [{ directive: "allow", address: v }] })], + ["/nginx/access-lists/{listID}", "put", (v) => ({ clients: [{ directive: "allow", address: v }] })], + ], + accessUsername: [ + ["/nginx/access-lists", "post", (v) => ({ name: "x", items: [{ username: v, password: "pw" }] })], + ["/nginx/access-lists/{listID}", "put", (v) => ({ items: [{ username: v, password: "pw" }] })], + ], + }; + + const validate = (path, method, payload) => { + const schema = getValidationSchema(path, method); + assert.ok(schema, `no request schema for ${method} ${path}`); + return apiValidator(schema, payload); + }; + + for (const [name, list] of Object.entries(endpoints)) { + for (const [path, method, build] of list) { + it(`${method.toUpperCase()} ${path} ${name}: rejects injections`, async () => { + for (const v of [...cases[name].injections, ...cases[name].rejected]) { + if (v === "") continue; // minLength cases, covered by the rules above + await assert.rejects(validate(path, method, build(v)), /must/, `accepted ${JSON.stringify(v)}`); + } + }); + it(`${method.toUpperCase()} ${path} ${name}: accepts valid values`, async () => { + for (const v of cases[name].valid) { + if (name === "forwardHost" && v === "") continue; + await validate(path, method, build(v)); + } + }); + } + } + + it("schema patterns are the ones the render-time check uses", async () => { + const s = await getCompiledSchema(); + const body = (path, method) => s.paths[path][method].requestBody.content["application/json"].schema; + const ph = body("/nginx/proxy-hosts", "post").properties; + const loc = ph.locations.items.properties; + const al = body("/nginx/access-lists", "post").properties; + assert.equal(ph.domain_names.items.pattern, patterns.domainName); + assert.equal(body("/nginx/certificates", "post").properties.domain_names.items.pattern, patterns.domainName); + assert.equal(ph.forward_host.pattern, patterns.forwardHost); + assert.equal(loc.path.pattern, patterns.locationPath); + assert.equal(loc.forward_host.pattern, patterns.forwardHost); + assert.equal(loc.forward_path.pattern, patterns.forwardPath); + assert.equal( + body("/nginx/redirection-hosts", "post").properties.forward_domain_name.pattern, + patterns.redirectDomain, + ); + assert.equal(body("/nginx/streams", "post").properties.forwarding_host.anyOf[0].pattern, patterns.streamDomain); + assert.equal(al.clients.items.properties.address.oneOf[1].pattern, patterns.clientIpv6); + assert.equal(al.items.items.properties.username.pattern, patterns.accessUsername); + }); +}); + +describe("rendered templates", () => { + const locationCtx = (field) => (v) => ({ + path: "/api", + forward_scheme: "http", + forward_host: "backend", + forward_port: 8080, + forward_path: "", + advanced_config: "", + access_list_id: 0, + [field]: v, + }); + const proxyCtx = (field) => (v) => ({ + enabled: true, + id: 1, + domain_names: ["example.com"], + forward_scheme: "http", + forward_host: "backend", + forward_port: 80, + certificate_id: 0, + locations: "", + advanced_config: "", + use_default_location: true, + ipv6: true, + [field]: v, + }); + const redirectCtx = (v) => ({ + enabled: true, + id: 1, + domain_names: ["example.com"], + forward_scheme: "http", + forward_http_code: 301, + forward_domain_name: v, + preserve_path: true, + advanced_config: "", + use_default_location: true, + ipv6: true, + }); + const streamCtx = (v) => ({ + enabled: true, + id: 1, + incoming_port: 9000, + forwarding_host: v, + forwarding_port: 80, + tcp_forwarding: true, + udp_forwarding: true, + ipv6: true, + }); + const accessCtx = (v) => ({ + access_list_id: 1, + access_list: { items: [], clients: [{ directive: "allow", address: v }], satisfy_any: false }, + }); + + // A location modifier is a separate argument, so compare with a plain path using the same one + const plainPath = (v) => { + const m = v.match(/^(=|~\*?|\^~) +/); + return m ? `${m[1]} /api` : "/api"; + }; + + // [template, context builder, plain value (or builder), injections that apply to it] + const targets = { + locationPath: [["_location.conf", locationCtx("path"), plainPath]], + forwardHost: [ + ["_location.conf", locationCtx("forward_host"), "backend"], + // Rendered inside double quotes here, so only a quote can break out + ["proxy_host.conf", proxyCtx("forward_host"), "backend", ['a"; return 200; set $x "', 'a" ; } server { "']], + ], + forwardPath: [["_location.conf", locationCtx("forward_path"), "/x"]], + redirectDomain: [["redirection_host.conf", redirectCtx, "example.org"]], + streamHost: [["stream.conf", streamCtx, "backend"]], + domainName: [["proxy_host.conf", (v) => proxyCtx("domain_names")([v]), "example.com"]], + clientAddress: [["_access.conf", accessCtx, "10.0.0.1"]], + }; + + for (const [name, list] of Object.entries(targets)) { + for (const [file, ctx, plain, injections = cases[name].injections] of list) { + const plainFor = (v) => (typeof plain === "function" ? plain(v) : plain); + it(`${file} ${name}: accepted values keep the statement structure`, async () => { + for (const v of cases[name].valid) { + if (v === "" && name !== "forwardPath") continue; + // generateConfig brackets IPv6 stream hosts before rendering + const value = name === "streamHost" && v.includes(":") ? `[${v}]` : v; + assert.ok( + await sameShapeAs(file, ctx, plainFor(v), value), + `${JSON.stringify(v)} changed the config`, + ); + } + }); + it(`${file} ${name}: the injection corpus would change it unchecked`, async () => { + assert.ok(injections.length > 0); + for (const v of injections) { + assert.ok(!checkers[name](v), `rules accept ${JSON.stringify(v)}`); + assert.ok( + !(await sameShapeAs(file, ctx, plainFor(v), v)), + `${JSON.stringify(v)} is not an injection`, + ); + } + }); + } + } + + it("location path renders as the location's own arguments", async () => { + for (const v of cases.locationPath.valid) { + const loc = find(parseNginx(await render("_location.conf", locationCtx("path")(v))), "location"); + assert.equal(loc.args.join(" "), v.replace(/ +/, " ")); + } + }); +}); + +describe("render-time check (rows saved before validation)", () => { + const host = (o) => ({ + id: 7, + domain_names: ["example.com"], + forward_host: "backend", + locations: [], + ...o, + }); + + it("passes valid hosts", () => { + assertHostRenderable("proxy_host", host({ locations: [{ path: "~* \\.php$", forward_host: "10.0.0.1/x/" }] })); + assertHostRenderable("redirection_host", host({ forward_domain_name: "example.org/path" })); + assertHostRenderable("dead_host", host({})); + assertHostRenderable("stream", { id: 1, forwarding_host: "::1" }); + assertHostRenderable( + "proxy_host", + host({ access_list: { clients: [{ directive: "allow", address: "10.0.0.0/8" }] } }), + ); + }); + + it("still renders stored values the API no longer accepts but that can't add directives", () => { + // e.g. an IPv6 literal server name, as the host-header smoke test renders + assertHostRenderable("proxy_host", host({ domain_names: ["host.example.test", "[::1]"] })); + assertHostRenderable("proxy_host", host({ forward_host: "$upstream" })); + assertHostRenderable("redirection_host", host({ forward_domain_name: "a" })); + assertHostRenderable("stream", { id: 1, forwarding_host: "a.com/x" }); + }); + + const refuse = (type, row, what) => { + assert.throws( + () => assertHostRenderable(type, row), + (err) => err.unsafeNginxValue === true && err.message.includes(what), + ); + }; + + it("refuses every injected field", () => { + for (const v of cases.locationPath.injections) { + refuse("proxy_host", host({ locations: [{ path: v, forward_host: "b" }] }), "location path"); + } + for (const v of cases.forwardHost.injections) { + refuse("proxy_host", host({ forward_host: v }), "forward host"); + refuse("proxy_host", host({ locations: [{ path: "/a", forward_host: v }] }), "location forward host"); + } + for (const v of cases.forwardPath.injections) { + refuse( + "proxy_host", + host({ locations: [{ path: "/a", forward_host: "b", forward_path: v }] }), + "forward path", + ); + } + for (const v of cases.redirectDomain.injections) { + refuse("redirection_host", host({ forward_domain_name: v }), "forward domain"); + } + for (const v of cases.streamHost.injections) { + refuse("stream", { id: 1, forwarding_host: v }, "forwarding host"); + } + for (const v of cases.domainName.injections) { + refuse("dead_host", host({ domain_names: [v] }), "domain name"); + } + for (const v of cases.clientAddress.injections) { + refuse("proxy_host", host({ access_list: { clients: [{ directive: "allow", address: v }] } }), "address"); + } + }); +}); diff --git a/scripts/ci/test-and-build b/scripts/ci/test-and-build index fec906d60..c7886718b 100755 --- a/scripts/ci/test-and-build +++ b/scripts/ci/test-and-build @@ -12,7 +12,7 @@ docker run --rm \ -v "$(pwd)/backend:/app" \ -w /app \ "${TESTING_IMAGE}" \ - sh -c 'yarn install && yarn lint . && rm -rf node_modules' + sh -c 'yarn install && yarn lint . && yarn test && rm -rf node_modules' echo -e "${BLUE}❯ ${GREEN}Testing Complete${RESET}" # Build diff --git a/test/cypress/e2e/api/NginxValues.cy.js b/test/cypress/e2e/api/NginxValues.cy.js new file mode 100644 index 000000000..7f1749764 --- /dev/null +++ b/test/cypress/e2e/api/NginxValues.cy.js @@ -0,0 +1,217 @@ +/// + +// Values a non-admin can set are written into nginx config as-is. None of them may add +// directives (that is what advanced_config is for, and only admins may set it), but the valid +// forms people use, such as regex locations, must still produce a config nginx accepts. +describe('Values rendered into nginx config', () => { + const user = { + email: 'nginx-values@example.com', + secret: 'changeme', + }; + + let token; + let userToken; + + const location = (overrides) => Object.assign({ + path: '/api', + forward_scheme: 'http', + forward_host: '127.0.0.1', + forward_port: 8080, + advanced_config: '', + }, overrides); + + const proxyHost = (domain, overrides) => Object.assign({ + domain_names: [domain], + forward_scheme: 'http', + forward_host: '127.0.0.1', + forward_port: 80, + access_list_id: '0', + certificate_id: 0, + meta: { dns_challenge: false }, + advanced_config: '', + locations: [], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false, + }, overrides || {}); + + const expectRejected = (path, data) => { + cy.task('backendApiPost', { + token: userToken, + path: path, + data: data, + returnOnError: true, + }).then((res) => { + expect(res, JSON.stringify(data)).to.have.property('error'); + expect(res.error.code, JSON.stringify(data)).to.equal(400); + }); + }; + + const expectOnline = (path, id) => { + cy.task('backendApiGet', { + token: userToken, + path: `${path}/${id}`, + }).then((row) => { + expect(row.meta.nginx_err || null).to.equal(null); + expect(row.meta.nginx_online).to.equal(true); + }); + }; + + before(() => { + cy.resetUsers(); + cy.getToken().then((tok) => { + token = tok; + cy.task('backendApiPost', { + token: token, + path: '/api/users', + data: { + name: 'Nginx Values', + nickname: 'Values', + email: user.email, + roles: [], + auth: { type: 'password', secret: user.secret }, + }, + }).then((created) => { + cy.task('backendApiPut', { + token: token, + path: `/api/users/${created.id}/permissions`, + data: { + visibility: 'user', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + dead_hosts: 'manage', + streams: 'manage', + access_lists: 'manage', + certificates: 'manage', + }, + }); + cy.getToken(null, { identity: user.email, secret: user.secret }).then((tok2) => { + userToken = tok2; + }); + }); + }); + }); + + it('Location paths and forward hosts that nginx accepts still work', () => { + cy.task('backendApiPost', { + token: userToken, + path: '/api/nginx/proxy-hosts', + data: proxyHost('nginx-values-ok.example.com', { + locations: [ + location({ path: '= /exact' }), + location({ path: '~* \\.(jpg|png|gif)$' }), + location({ path: '^~ /static/' }), + location({ path: '~ "^/v[0-9]{1,2}/"' }), + location({ path: '/sub', forward_host: '127.0.0.1/sub/' }), + ], + }), + }).then((host) => { + expectOnline('/api/nginx/proxy-hosts', host.id); + }); + }); + + it('Redirect targets with a port and path still work', () => { + cy.task('backendApiPost', { + token: userToken, + path: '/api/nginx/redirection-hosts', + data: { + domain_names: ['nginx-values-redirect.example.com'], + forward_scheme: 'https', + forward_http_code: 301, + forward_domain_name: 'example.org:8443/landing', + preserve_path: true, + certificate_id: 0, + meta: {}, + advanced_config: '', + block_exploits: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false, + }, + }).then((host) => { + expectOnline('/api/nginx/redirection-hosts', host.id); + }); + }); + + it('Location path cannot add directives', () => { + [ + '/x { return 200; } location /y', + '/a; } server { listen 8080', + '/a\nreturn 200 x;\nlocation /b', + '/a {', + '~', + ].forEach((path) => { + expectRejected('/api/nginx/proxy-hosts', proxyHost('nginx-values-bad.example.com', { + locations: [location({ path })], + })); + }); + }); + + it('Forward hosts cannot add directives', () => { + [ + 'a; return 200; #', + 'a"; return 200; set $x "', + 'a:80; } location /x { return 200', + '$http_host', + ].forEach((forward_host) => { + expectRejected('/api/nginx/proxy-hosts', proxyHost('nginx-values-bad.example.com', { forward_host })); + expectRejected('/api/nginx/proxy-hosts', proxyHost('nginx-values-bad.example.com', { + locations: [location({ forward_host })], + })); + }); + expectRejected('/api/nginx/proxy-hosts', proxyHost('nginx-values-bad.example.com', { + locations: [location({ forward_path: '/x; return 200' })], + })); + }); + + it('Redirect target cannot add directives', () => { + ['example.com; return 200 x; #', 'a.com { return 200; }'].forEach((forward_domain_name) => { + expectRejected('/api/nginx/redirection-hosts', { + domain_names: ['nginx-values-bad-redirect.example.com'], + forward_scheme: 'http', + forward_http_code: 301, + forward_domain_name, + }); + }); + }); + + it('Stream forwarding host cannot add directives', () => { + ['a.com:80; } server { listen 9999', 'a.com/x'].forEach((forwarding_host) => { + expectRejected('/api/nginx/streams', { + incoming_port: 1600, + forwarding_host, + forwarding_port: 80, + tcp_forwarding: true, + udp_forwarding: false, + }); + }); + }); + + it('Domain names cannot add directives', () => { + ['a.example.com\nreturn 200 x', 'a\tb.example.com'].forEach((domain) => { + expectRejected('/api/nginx/dead-hosts', { domain_names: [domain] }); + }); + }); + + it('Access list entries cannot add directives or corrupt the password file', () => { + ['::1%x; allow all', '1.2.3.4; allow all', 'fe80::1%eth0'].forEach((address) => { + expectRejected('/api/nginx/access-lists', { + name: 'Bad client', + items: [], + clients: [{ directive: 'deny', address }], + }); + }); + ['a:b', 'a\nroot:$apr1$x$y'].forEach((username) => { + expectRejected('/api/nginx/access-lists', { + name: 'Bad user', + items: [{ username, password: 'pw' }], + clients: [], + }); + }); + }); +});