mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-10 12:28:28 +00:00
advanced_config is admin-only, but other fields are written into nginx
config as-is and the schema barely limited them. A non-admin could add
any directive through them:
- location path, forward_host and forward_path (any non-empty string)
- proxy host forward_host (a quote breaks out of set $server "...")
- redirection forward_domain_name and stream forwarding_host (their
pattern allowed ";", e.g. "example.com; return 200; #")
- access list IPv6 addresses ("%.+" zone id allowed "::1%x; allow all")
- domain names (a newline got out of the header comment)
Validation
- New lib/nginx-values.js holds the rules. Values can't contain
characters that end or change an nginx token: whitespace, control
characters, ; { } " ' # \, and no $ in a host. Location paths allow a
modifier and an unquoted or double-quoted URI, so =, ~, ~* and ^~
locations and quoted regexes keep working.
- The API schemas use the same patterns, including the certificate
schema's own copy of the domain rule. A test fails if they drift.
- generateConfig checks stored values again, so rows saved before these
rules can't inject either. The check is about nginx structure only:
a stored value the API would now reject, but which can't add
directives, still renders. If a value is unsafe, nothing is written
and the host is marked offline with the reason in nginx_err. The same
check covers per-location access lists and the Let's Encrypt request
config.
- htpasswd user names can't contain ":" or control characters, which
corrupted the file.
Fixes along the way
- IPv6 client addresses: drop zone ids, which nginx's allow/deny
rejects anyway, and fix broken escapes (literal "d" for digits, an
unescaped ".").
- Rewrite the domain rule used by redirection and stream hosts so it
can't backtrack exponentially. It accepts the same values.
- Pattern errors still say "must match pattern", but no longer quote
the regex.
Tests
- backend/test/nginx-values.test.js, run by `yarn test` and in
scripts/ci/test-and-build. Real request schemas reject an injection
set and accept valid values on every endpoint. Accepted values,
rendered through the real templates and parsed using nginx's
tokenizer rules, keep the same statement structure. The check
refuses unsafe stored rows.
- NginxValues.cy.js: injection attempts from a non-admin get 400, and
hosts using valid edge-case values come up with nginx_online true.
577 lines
17 KiB
JavaScript
577 lines
17 KiB
JavaScript
import fs from "node:fs";
|
|
import net from "node:net";
|
|
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";
|
|
import certificateModel from "../models/certificate.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
const internalNginx = {
|
|
/**
|
|
* This will:
|
|
* - test the nginx config first to make sure it's OK
|
|
* - create / recreate the config for the host
|
|
* - test again
|
|
* - IF OK: update the meta with online status
|
|
* - IF BAD: update the meta with offline status and remove the config entirely
|
|
* - then reload nginx
|
|
*
|
|
* @param {Object|String} model
|
|
* @param {String} host_type
|
|
* @param {Object} host
|
|
* @returns {Promise}
|
|
*/
|
|
configure: (model, host_type, host) => {
|
|
let combined_meta = {};
|
|
|
|
return internalNginx
|
|
.test()
|
|
.then(() => {
|
|
// Nginx is OK
|
|
// We're deleting this config regardless.
|
|
// Don't throw errors, as the file may not exist at all
|
|
// Delete the .err file too
|
|
return internalNginx.deleteConfig(host_type, host, true);
|
|
})
|
|
.then(() => {
|
|
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((generated) => {
|
|
if (!generated) {
|
|
return;
|
|
}
|
|
// Test nginx again and update meta with result
|
|
return internalNginx
|
|
.test()
|
|
.then(() => {
|
|
// nginx is ok
|
|
combined_meta = _.assign({}, host.meta, {
|
|
nginx_online: true,
|
|
nginx_err: null,
|
|
});
|
|
|
|
return model.query().where("id", host.id).patch({
|
|
meta: combined_meta,
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
// Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported.
|
|
// It will always look like this:
|
|
// nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (6: No such device or address)
|
|
|
|
const valid_lines = [];
|
|
const err_lines = err.message.split("\n");
|
|
err_lines.map((line) => {
|
|
if (line.indexOf("/var/log/nginx/error.log") === -1) {
|
|
valid_lines.push(line);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
debug(logger, "Nginx test failed:", valid_lines.join("\n"));
|
|
|
|
// config is bad, update meta and delete config
|
|
combined_meta = _.assign({}, host.meta, {
|
|
nginx_online: false,
|
|
nginx_err: valid_lines.join("\n"),
|
|
});
|
|
|
|
return model
|
|
.query()
|
|
.where("id", host.id)
|
|
.patch({
|
|
meta: combined_meta,
|
|
})
|
|
.then(() => {
|
|
// Keep the failed config as a .err file for inspection
|
|
return internalNginx.renameConfigAsError(host_type, host);
|
|
})
|
|
.then(() => {
|
|
// The rename removed the live config already, don't touch the .err file
|
|
return internalNginx.deleteConfig(host_type, host, false);
|
|
});
|
|
});
|
|
})
|
|
.then(() => {
|
|
return internalNginx.reload();
|
|
})
|
|
.then(() => {
|
|
return combined_meta;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @returns {Promise}
|
|
*/
|
|
test: () => {
|
|
debug(logger, "Testing Nginx configuration");
|
|
return utils.execFile("/usr/sbin/nginx", ["-t", "-g", "error_log off;"]);
|
|
},
|
|
|
|
/**
|
|
* @returns {Promise}
|
|
*/
|
|
reload: () => {
|
|
return internalNginx.test().then(() => {
|
|
logger.info("Reloading Nginx");
|
|
return utils.execFile("/usr/sbin/nginx", ["-s", "reload"]);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Integer} host_id
|
|
* @returns {String}
|
|
*/
|
|
getConfigName: (host_type, host_id) => {
|
|
if (host_type === "default") {
|
|
return "/data/nginx/default_host/site.conf";
|
|
}
|
|
return `/data/nginx/${internalNginx.getFileFriendlyHostType(host_type)}/${host_id}.conf`;
|
|
},
|
|
|
|
/**
|
|
* Generates custom locations
|
|
* @param {Object} host
|
|
* @returns {Promise}
|
|
*/
|
|
renderLocations: (host) => {
|
|
return new Promise((resolve, reject) => {
|
|
let template;
|
|
|
|
try {
|
|
template = fs.readFileSync(`${__dirname}/../templates/_location.conf`, { encoding: "utf8" });
|
|
} catch (err) {
|
|
reject(new errs.ConfigurationError(err.message));
|
|
return;
|
|
}
|
|
|
|
const renderEngine = utils.getRenderEngine();
|
|
let renderedLocations = "";
|
|
|
|
const locationRendering = async () => {
|
|
for (let i = 0; i < host.locations.length; i++) {
|
|
const locationCopy = Object.assign(
|
|
{},
|
|
{ access_list_id: host.access_list_id },
|
|
{ certificate_id: host.certificate_id },
|
|
{ ssl_forced: host.ssl_forced },
|
|
{ caching_enabled: host.caching_enabled },
|
|
{ block_exploits: host.block_exploits },
|
|
{ allow_websocket_upgrade: host.allow_websocket_upgrade },
|
|
{ http2_support: host.http2_support },
|
|
{ hsts_enabled: host.hsts_enabled },
|
|
{ hsts_subdomains: host.hsts_subdomains },
|
|
{ access_list: host.access_list },
|
|
{ certificate: host.certificate },
|
|
host.locations[i],
|
|
);
|
|
|
|
// A location with its own access list overrides the host's,
|
|
// otherwise it inherits the host's access list
|
|
let locationAccessList = null;
|
|
if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
|
|
locationAccessList = await accessListModel
|
|
.query()
|
|
.where("is_deleted", 0)
|
|
.andWhere("id", locationCopy.access_list_id)
|
|
.withGraphFetched("[clients,items]")
|
|
.first();
|
|
}
|
|
if (locationAccessList) {
|
|
assertAccessListRenderable(
|
|
`Proxy Host #${host.id} location ${locationCopy.path}`,
|
|
locationAccessList,
|
|
);
|
|
locationCopy.access_list = locationAccessList;
|
|
} else if (
|
|
locationCopy.access_list_id > 0 &&
|
|
locationCopy.access_list_id !== host.access_list?.id
|
|
) {
|
|
// The location names its own list but it's gone. Don't fall back to the
|
|
// host's (possibly less restrictive) list; _access.conf denies all instead.
|
|
logger.error(
|
|
`Proxy Host #${host.id} location "${locationCopy.path}" references Access List #${locationCopy.access_list_id} which does not exist, all access will be denied`,
|
|
);
|
|
locationCopy.access_list = null;
|
|
} else {
|
|
locationCopy.access_list_id = host.access_list_id;
|
|
locationCopy.access_list = host.access_list;
|
|
}
|
|
|
|
if (locationCopy.forward_host.indexOf("/") > -1) {
|
|
const splitted = locationCopy.forward_host.split("/");
|
|
|
|
locationCopy.forward_host = splitted.shift();
|
|
locationCopy.forward_path = `/${splitted.join("/")}`;
|
|
}
|
|
|
|
renderedLocations += await renderEngine.parseAndRender(template, locationCopy);
|
|
}
|
|
};
|
|
|
|
locationRendering()
|
|
.then(() => resolve(renderedLocations))
|
|
.catch(reject);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Object} host
|
|
* @returns {Promise}
|
|
*/
|
|
generateConfig: async (host_type, host_row) => {
|
|
// Prevent modifying the original object:
|
|
const host = JSON.parse(JSON.stringify(host_row));
|
|
const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
|
|
|
|
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();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let template = null;
|
|
const filename = internalNginx.getConfigName(nice_host_type, host.id);
|
|
|
|
try {
|
|
template = fs.readFileSync(`${__dirname}/../templates/${nice_host_type}.conf`, { encoding: "utf8" });
|
|
} catch (err) {
|
|
reject(new errs.ConfigurationError(err.message));
|
|
return;
|
|
}
|
|
|
|
let locationsPromise;
|
|
let origLocations;
|
|
|
|
// Manipulate the data a bit before sending it to the template
|
|
if (nice_host_type !== "default") {
|
|
host.use_default_location = true;
|
|
if (typeof host.advanced_config !== "undefined" && host.advanced_config) {
|
|
host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config);
|
|
}
|
|
}
|
|
|
|
// For redirection hosts, if the scheme is not http or https, set it to $scheme
|
|
if (
|
|
nice_host_type === "redirection_host" &&
|
|
["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
|
|
) {
|
|
host.forward_scheme = "$scheme";
|
|
}
|
|
|
|
// A stream forwarding to an IPv6 literal must have the address wrapped in
|
|
// square brackets before nginx appends ":<port>". Without the brackets nginx
|
|
// reads the trailing ":<port>" as part of the address and rejects the upstream
|
|
// ("invalid port in upstream"), so the stream saves but never activates (#5740).
|
|
if (nice_host_type === "stream" && net.isIPv6(host.forwarding_host)) {
|
|
host.forwarding_host = `[${host.forwarding_host}]`;
|
|
}
|
|
|
|
if (host.locations) {
|
|
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
|
|
origLocations = [].concat(host.locations);
|
|
locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => {
|
|
host.locations = renderedLocations;
|
|
});
|
|
|
|
// Allow someone who is using / custom location path to use it, and skip the default / location
|
|
_.map(host.locations, (location) => {
|
|
if (location.path === "/") {
|
|
host.use_default_location = false;
|
|
}
|
|
});
|
|
} else {
|
|
locationsPromise = Promise.resolve();
|
|
}
|
|
|
|
// Set the IPv6 setting for the host
|
|
host.ipv6 = internalNginx.ipv6Enabled();
|
|
|
|
locationsPromise
|
|
.then(() => {
|
|
renderEngine
|
|
.parseAndRender(template, host)
|
|
.then((config_text) => {
|
|
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
|
debug(logger, "Wrote config:", filename, config_text);
|
|
|
|
// Restore locations array
|
|
host.locations = origLocations;
|
|
|
|
resolve(true);
|
|
})
|
|
.catch((err) => {
|
|
debug(logger, `Could not write ${filename}:`, err.message);
|
|
reject(new errs.ConfigurationError(err.message));
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
debug(logger, `Could not render locations for ${filename}:`, err.message);
|
|
reject(new errs.ConfigurationError(err.message));
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* The templates rely on the certificate and access list relations being loaded.
|
|
* Not every caller fetches them (or fetches them fully), and when they're missing
|
|
* the config silently renders without SSL or without any access rules (#5919).
|
|
* Load anything that is referenced but not loaded onto the given host object.
|
|
*
|
|
* @param {String} host_type
|
|
* @param {Object} host
|
|
* @returns {Promise}
|
|
*/
|
|
loadRenderRelations: async (host_type, host) => {
|
|
if (host.certificate_id > 0 && host.certificate?.id !== host.certificate_id) {
|
|
const certificate = await certificateModel
|
|
.query()
|
|
.where("is_deleted", 0)
|
|
.andWhere("id", host.certificate_id)
|
|
.first();
|
|
host.certificate = certificate ? JSON.parse(JSON.stringify(certificate)) : null;
|
|
}
|
|
|
|
if (host_type === "proxy_host" && host.access_list_id > 0) {
|
|
if (
|
|
host.access_list?.id !== host.access_list_id ||
|
|
!Array.isArray(host.access_list.clients) ||
|
|
!Array.isArray(host.access_list.items)
|
|
) {
|
|
const accessList = await accessListModel
|
|
.query()
|
|
.where("is_deleted", 0)
|
|
.andWhere("id", host.access_list_id)
|
|
.withGraphFetched("[clients,items]")
|
|
.first();
|
|
host.access_list = accessList ? JSON.parse(JSON.stringify(accessList)) : null;
|
|
}
|
|
|
|
if (!host.access_list) {
|
|
// _access.conf renders "deny all" when the list is missing, so fail closed
|
|
logger.error(
|
|
`Proxy Host #${host.id} references Access List #${host.access_list_id} which does not exist, all access will be denied`,
|
|
);
|
|
} else if (!host.access_list.clients.length && !host.access_list.items.length) {
|
|
logger.warn(
|
|
`Proxy Host #${host.id} uses Access List #${host.access_list_id} which has no clients or authorizations, no access restrictions will apply`,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* This generates a temporary nginx config listening on port 80 for the domain names listed
|
|
* in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
|
|
* when requesting a certificate without having a hostname set up already.
|
|
*
|
|
* @param {Object} certificate
|
|
* @returns {Promise}
|
|
*/
|
|
generateLetsEncryptRequestConfig: (certificate) => {
|
|
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`;
|
|
|
|
try {
|
|
template = fs.readFileSync(`${__dirname}/../templates/letsencrypt-request.conf`, { encoding: "utf8" });
|
|
} catch (err) {
|
|
reject(new errs.ConfigurationError(err.message));
|
|
return;
|
|
}
|
|
|
|
certificate.ipv6 = internalNginx.ipv6Enabled();
|
|
|
|
renderEngine
|
|
.parseAndRender(template, certificate)
|
|
.then((config_text) => {
|
|
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
|
debug(logger, "Wrote config:", filename, config_text);
|
|
resolve(true);
|
|
})
|
|
.catch((err) => {
|
|
debug(logger, `Could not write ${filename}:`, err.message);
|
|
reject(new errs.ConfigurationError(err.message));
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* A simple wrapper around unlinkSync that writes to the logger
|
|
*
|
|
* @param {String} filename
|
|
*/
|
|
deleteFile: (filename) => {
|
|
if (!fs.existsSync(filename)) {
|
|
return;
|
|
}
|
|
try {
|
|
debug(logger, `Deleting file: ${filename}`);
|
|
fs.unlinkSync(filename);
|
|
} catch (err) {
|
|
debug(logger, "Could not delete file:", JSON.stringify(err, null, 2));
|
|
}
|
|
},
|
|
|
|
/**
|
|
*
|
|
* @param {String} host_type
|
|
* @returns String
|
|
*/
|
|
getFileFriendlyHostType: (host_type) => {
|
|
return host_type.replace(/-/g, "_");
|
|
},
|
|
|
|
/**
|
|
* This removes the temporary nginx config file generated by `generateLetsEncryptRequestConfig`
|
|
*
|
|
* @param {Object} certificate
|
|
* @returns {Promise}
|
|
*/
|
|
deleteLetsEncryptRequestConfig: (certificate) => {
|
|
const config_file = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
|
|
return new Promise((resolve /*, reject*/) => {
|
|
internalNginx.deleteFile(config_file);
|
|
resolve();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Object} [host]
|
|
* @param {Boolean} [delete_err_file]
|
|
* @returns {Promise}
|
|
*/
|
|
deleteConfig: (host_type, host, delete_err_file) => {
|
|
const config_file = internalNginx.getConfigName(
|
|
internalNginx.getFileFriendlyHostType(host_type),
|
|
typeof host === "undefined" ? 0 : host.id,
|
|
);
|
|
const config_file_err = `${config_file}.err`;
|
|
|
|
return new Promise((resolve /*, reject*/) => {
|
|
internalNginx.deleteFile(config_file);
|
|
if (delete_err_file) {
|
|
internalNginx.deleteFile(config_file_err);
|
|
}
|
|
resolve();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Object} [host]
|
|
* @returns {Promise}
|
|
*/
|
|
renameConfigAsError: (host_type, host) => {
|
|
const config_file = internalNginx.getConfigName(
|
|
internalNginx.getFileFriendlyHostType(host_type),
|
|
typeof host === "undefined" ? 0 : host.id,
|
|
);
|
|
const config_file_err = `${config_file}.err`;
|
|
|
|
return new Promise((resolve /*, reject*/) => {
|
|
fs.unlink(config_file_err, () => {
|
|
// ignore result, a previous .err file may not exist
|
|
fs.rename(config_file, config_file_err, () => {
|
|
// also ignore result, as this is a debugging informative file anyway
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} hostType
|
|
* @param {Array} hosts
|
|
* @returns {Promise}
|
|
*/
|
|
bulkGenerateConfigs: (hostType, hosts) => {
|
|
const promises = [];
|
|
hosts.map((host) => {
|
|
promises.push(internalNginx.generateConfig(hostType, host));
|
|
return true;
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Array} hosts
|
|
* @returns {Promise}
|
|
*/
|
|
bulkDeleteConfigs: (host_type, hosts) => {
|
|
const promises = [];
|
|
hosts.map((host) => {
|
|
promises.push(internalNginx.deleteConfig(host_type, host, true));
|
|
return true;
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
},
|
|
|
|
/**
|
|
* @param {string} config
|
|
* @returns {boolean}
|
|
*/
|
|
advancedConfigHasDefaultLocation: (cfg) => !!cfg.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im),
|
|
|
|
/**
|
|
* @returns {boolean}
|
|
*/
|
|
ipv6Enabled: () => {
|
|
if (typeof process.env.DISABLE_IPV6 !== "undefined") {
|
|
const disabled = process.env.DISABLE_IPV6.toLowerCase();
|
|
return !(disabled === "on" || disabled === "true" || disabled === "1" || disabled === "yes");
|
|
}
|
|
|
|
return true;
|
|
},
|
|
};
|
|
|
|
export default internalNginx;
|