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.
543 lines
15 KiB
JavaScript
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;
|