Files
nginx-proxy-manager/backend/internal/access-list.js
T
Jamie Curnow 66ab4b24d1 Stop non-admins injecting nginx directives through host fields
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.
2026-11-10 08:20:02 +10:00

543 lines
15 KiB
JavaScript

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";
import { access as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
import accessListAuthModel from "../models/access_list_auth.js";
import accessListClientModel from "../models/access_list_client.js";
import proxyHostModel from "../models/proxy_host.js";
import internalAuditLog from "./audit-log.js";
import internalNginx from "./nginx.js";
const omissions = () => {
return ["is_deleted"];
};
/**
* Find proxy hosts that reference an access list in their locations JSON.
*
* @param {Integer} accessListId
* @returns {Promise<Array>}
*/
const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
let result;
if (isMysql()) {
const searchObj = JSON.stringify([{ access_list_id: accessListId }]);
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)", [
searchObj,
"$",
]);
} else if (isPostgres()) {
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb", [
JSON.stringify([{ access_list_id: accessListId }]),
]);
} else {
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?", [
`%"access_list_id":${accessListId}%`,
]);
}
// knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
if (!Array.isArray(result)) {
return result?.rows || [];
}
return (Array.isArray(result[0]) ? result[0] : result) || [];
};
const internalAccessList = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: async (access, data) => {
await access.can("access_lists:create", data);
const row = await accessListModel
.query()
.insertAndFetch({
name: data.name,
satisfy_any: data.satisfy_any,
pass_auth: data.pass_auth,
owner_user_id: access.token.getUserId(1),
})
.then(utils.omitRow(omissions()));
data.id = row.id;
const promises = [];
// Items
data.items.map((item) => {
promises.push(
accessListAuthModel.query().insert({
access_list_id: row.id,
username: item.username,
password: item.password,
}),
);
return true;
});
await Promise.all(promises);
// Clients
for (const client of data.clients ?? []) {
await accessListClientModel.query().insert({
access_list_id: row.id,
address: client.address,
directive: client.directive,
});
}
// re-fetch with expansions
const freshRow = await internalAccessList.get(
access,
{
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
},
true, // skip masking
);
// Audit log
data.meta = _.assign({}, data.meta || {}, freshRow.meta);
await internalAccessList.build(freshRow);
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
}
// Add to audit log
await internalAuditLog.add(access, {
action: "created",
object_type: "access-list",
object_id: freshRow.id,
meta: internalAccessList.maskItems(data),
});
return internalAccessList.maskItems(freshRow);
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.name]
* @param {String} [data.items]
* @return {Promise}
*/
update: async (access, data) => {
await access.can("access_lists:update", data.id);
const row = await internalAccessList.get(access, { id: data.id });
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new errs.InternalValidationError(
`Access List could not be updated, IDs do not match: ${row.id} !== ${data.id}`,
);
}
// patch name if specified
if (typeof data.name !== "undefined" && data.name) {
await accessListModel.query().where({ id: data.id }).patch({
name: data.name,
satisfy_any: data.satisfy_any,
pass_auth: data.pass_auth,
});
}
// Check for items and add/update/remove them
if (typeof data.items !== "undefined" && data.items) {
const promises = [];
const itemsToKeep = [];
data.items.map((item) => {
if (item.password) {
promises.push(
accessListAuthModel.query().insert({
access_list_id: data.id,
username: item.username,
password: item.password,
}),
);
} else {
// This was supplied with an empty password, which means keep it but don't change the password
itemsToKeep.push(item.username);
}
return true;
});
const query = accessListAuthModel.query().delete().where("access_list_id", data.id);
if (itemsToKeep.length) {
query.andWhere("username", "NOT IN", itemsToKeep);
}
await query;
// Add new items
if (promises.length) {
await Promise.all(promises);
}
}
// Check for clients and add/update/remove them
if (typeof data.clients !== "undefined" && data.clients) {
const query = accessListClientModel.query().delete().where("access_list_id", data.id);
await query;
for (const client of data.clients) {
if (client.address) {
await accessListClientModel.query().insert({
access_list_id: data.id,
address: client.address,
directive: client.directive,
});
}
}
}
// Add to audit log
await internalAuditLog.add(access, {
action: "updated",
object_type: "access-list",
object_id: data.id,
meta: internalAccessList.maskItems(data),
});
// re-fetch with expansions
const freshRow = await internalAccessList.get(
access,
{
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
},
true, // skip masking
);
await internalAccessList.build(freshRow);
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
}
// Also regenerate configs for proxy hosts that reference this access list in their locations
const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id);
if (locationHostRows?.length) {
const locationHostIds = locationHostRows
.map((r) => r.id)
.filter((id) => {
// Exclude hosts already regenerated above
return !freshRow.proxy_hosts?.find((h) => h.id === id);
});
if (locationHostIds.length) {
const locationHosts = await proxyHostModel
.query()
.where("is_deleted", 0)
.whereIn("id", locationHostIds)
.allowGraph(proxyHostModel.defaultAllowGraph)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
}
}
await internalNginx.reload();
return internalAccessList.maskItems(freshRow);
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @param {Boolean} [skipMasking]
* @return {Promise}
*/
get: async (access, data, skipMasking) => {
const thisData = data || {};
const accessData = await access.can("access_lists:get", thisData.id);
const query = accessListModel
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.andWhere("access_list.id", thisData.id)
.groupBy("access_list.id")
.allowGraph("[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]")
.first();
if (accessData.permission_visibility !== "all") {
query.andWhere("access_list.owner_user_id", access.token.getUserId(1));
}
if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
query.withGraphFetched(`[${thisData.expand.join(", ")}]`);
}
let row = await query.then(utils.omitRow(omissions()));
if (!row?.id) {
throw new errs.ItemNotFoundError(thisData.id);
}
if (!skipMasking && typeof row.items !== "undefined" && row.items) {
row = internalAccessList.maskItems(row);
}
// Custom omissions
if (typeof data.omit !== "undefined" && data.omit !== null) {
row = _.omit(row, data.omit);
}
return row;
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: async (access, data) => {
await access.can("access_lists:delete", data.id);
const row = await internalAccessList.get(access, {
id: data.id,
expand: ["proxy_hosts", "items", "clients"],
});
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
// 1. update row to be deleted
// 2. update any proxy hosts that were using it (ignoring permissions)
// 3. reconfigure those hosts
// 4. audit log
// 1. update row to be deleted
await accessListModel.query().where("id", row.id).patch({
is_deleted: 1,
});
// 2. update any proxy hosts that were using it (ignoring permissions)
const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
if (affectedHostIds.size) {
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
}
// Also clear it from any proxy host locations using it, these will then inherit the host's access list
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
for (const { id: hostId } of locationHostRows) {
const host = await proxyHostModel.query().where("id", hostId).first();
if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
const updatedLocations = host.locations.map((loc) => {
if (loc.access_list_id === row.id) {
return { ...loc, access_list_id: 0 };
}
return loc;
});
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
affectedHostIds.add(hostId);
}
}
// 3. reconfigure those hosts from fresh rows, then reload nginx
if (affectedHostIds.size) {
const affectedHosts = await proxyHostModel
.query()
.where("is_deleted", 0)
.whereIn("id", [...affectedHostIds])
.allowGraph(proxyHostModel.defaultAllowGraph)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
}
await internalNginx.reload();
// delete the htpasswd file
try {
fs.unlinkSync(internalAccessList.getFilename(row));
} catch (_err) {
// do nothing
}
// 4. audit log
await internalAuditLog.add(access, {
action: "deleted",
object_type: "access-list",
object_id: row.id,
meta: _.omit(internalAccessList.maskItems(row), ["is_deleted", "proxy_hosts"]),
});
return true;
},
/**
* All Lists
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [searchQuery]
* @returns {Promise}
*/
getAll: async (access, expand, searchQuery) => {
const accessData = await access.can("access_lists:list");
const query = accessListModel
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.groupBy("access_list.id")
.allowGraph("[owner,items,clients]")
.orderBy("access_list.name", "ASC");
if (accessData.permission_visibility !== "all") {
query.andWhere("access_list.owner_user_id", access.token.getUserId(1));
}
// Query is used for searching
if (typeof searchQuery === "string") {
query.where(function () {
this.where("name", "like", `%${searchQuery}%`);
});
}
if (typeof expand !== "undefined" && expand !== null) {
query.withGraphFetched(`[${expand.join(", ")}]`);
}
const rows = await query.then(utils.omitRows(omissions()));
if (rows) {
rows.map((row, idx) => {
if (typeof row.items !== "undefined" && row.items) {
rows[idx] = internalAccessList.maskItems(row);
}
return true;
});
}
return rows;
},
/**
* Count is used in reports
*
* @param {Integer} userId
* @param {String} visibility
* @returns {Promise}
*/
getCount: async (userId, visibility) => {
const query = accessListModel.query().count("id as count").where("is_deleted", 0);
if (visibility !== "all") {
query.andWhere("owner_user_id", userId);
}
const row = await query.first();
return Number.parseInt(row.count, 10);
},
/**
* @param {Object} list
* @returns {Object}
*/
maskItems: (list) => {
if (list && typeof list.items !== "undefined") {
list.items.map((val, idx) => {
let repeatFor = 8;
let firstChar = "*";
if (typeof val.password !== "undefined" && val.password) {
repeatFor = val.password.length - 1;
firstChar = val.password.charAt(0);
}
list.items[idx].hint = firstChar + "*".repeat(repeatFor);
list.items[idx].password = "";
return true;
});
}
return list;
},
/**
* @param {Object} list
* @param {Integer} list.id
* @returns {String}
*/
getFilename: (list) => {
return `/data/access/${list.id}`;
},
/**
* @param {Object} list
* @param {Integer} list.id
* @param {String} list.name
* @param {Array} list.items
* @returns {Promise}
*/
build: async (list) => {
logger.info(`Building Access file #${list.id} for: ${list.name}`);
const htpasswdFile = internalAccessList.getFilename(list);
// 1. remove any existing access file
try {
fs.unlinkSync(htpasswdFile);
} catch (_err) {
// do nothing
}
// 2. create empty access file
fs.writeFileSync(htpasswdFile, "", { encoding: "utf8" });
// 3. generate password for each user
if (list.items.length) {
await new Promise((resolve, reject) => {
batchflow(list.items)
.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
.execFile("openssl", ["passwd", "-apr1", item.password])
.then((res) => {
try {
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {
encoding: "utf8",
});
} catch (err) {
reject(err);
}
next();
})
.catch((err) => {
logger.error(err);
next(err);
});
}
})
.error((err) => {
logger.error(err);
reject(err);
})
.end((results) => {
logger.success(`Built Access file #${list.id} for: ${list.name}`);
resolve(results);
});
});
}
},
};
export default internalAccessList;