mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-14 05:08:28 +00:00
Security - Check advanced_config permissions before creating a certificate on host update (proxy, redirection and 404 hosts). A refused request previously requested a Let's Encrypt certificate first, leaving an orphan certificate and using up rate limit. - Only trust X-NPM-Peer-Addr when it comes with a secret generated at container start. The admin nginx sends both headers. Any proxy host or stream forwarding to 127.0.0.1:3000 was also a loopback peer, so it could forge the header and get a fresh rate-limit bucket on every request. Without the secret (dev, or a mounted production.conf), the header is ignored. - Add a login limit per IP across all accounts (30 failed attempts a minute) alongside the per-account one. Before this, one address could spray passwords over any number of accounts. Behind a shared proxy, everyone using that proxy shares this limit. - Stop trusting X-Real-IP from loopback. It was only there for an older version of the rate limiter, and it let a stream host forwarding to a local proxy host forge the client IP and get past IP access lists. Loopback now behaves as it does on master. Correctness - Refuse to delete a certificate that hosts still use (400, with counts per host type). Their configs include its files, and a successful revoke deleted them anyway, which broke nginx -t. Otherwise the certificate is revoked and its files are removed even if the revoke fails. - Let non-admins clear a location's advanced config. Deleting the location was already allowed and has the same effect, and a location deleted and re-added at the same path arrives with an empty config, which was refused. - Clear proxy host grants (meta.proxy_host_ids) when visibility isn't "user" or proxy hosts are hidden, and drop ids of deleted hosts. Before, hidden grants came back when proxy hosts were shown again. The backend enforces this; the permissions modal also stops sending them. - 404 host and redirection host modals now wait for the current user to load, so an admin's advanced config change isn't dropped if they submit before it loads. Tests - NonAdminHosts.cy.js: refused change doesn't create a certificate, location rename and clear rules, grant clearing. - Certificates.cy.js: deleting a certificate that is in use.
156 lines
5.1 KiB
JavaScript
156 lines
5.1 KiB
JavaScript
import crypto from "node:crypto";
|
|
import { isCI } from "../config.js";
|
|
|
|
/**
|
|
* In-memory fixed window rate limiter for authentication endpoints
|
|
*/
|
|
|
|
// Keys longer than this are hashed, so a client can't make the store hold arbitrarily large
|
|
// strings (keys can include request body values such as the login identity).
|
|
const MAX_KEY_LENGTH = 128;
|
|
// Entries per limiter. A flood of unique keys sweeps expired entries, then evicts the oldest,
|
|
// instead of growing the store until the periodic cleanup runs.
|
|
const MAX_STORE_SIZE = 50000;
|
|
|
|
// One store per limiter, so separate endpoints never share buckets
|
|
const stores = new Set();
|
|
|
|
// Clean up old entries every 2 minutes
|
|
const cleanupTimer = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const store of stores) {
|
|
for (const [key, record] of store.entries()) {
|
|
if (now - record.startTime > record.windowMs * 2) {
|
|
store.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}, 120000);
|
|
cleanupTimer.unref();
|
|
|
|
// Generated at container start (prepare/45-admin-port.sh) and sent by the admin nginx as
|
|
// X-NPM-Peer-Secret alongside X-NPM-Peer-Addr. Unset (development, or a mounted production.conf)
|
|
// means the header is never trusted.
|
|
const peerSecret = Buffer.from(process.env.NPM_PEER_SECRET || "");
|
|
|
|
const hasPeerSecret = (value) => {
|
|
if (!peerSecret.length || typeof value !== "string") {
|
|
return false;
|
|
}
|
|
const given = Buffer.from(value);
|
|
return given.length === peerSecret.length && crypto.timingSafeEqual(given, peerSecret);
|
|
};
|
|
|
|
/**
|
|
* The address to rate limit on: the TCP peer of the admin nginx, which it passes as
|
|
* X-NPM-Peer-Addr ($realip_remote_addr). That header is only honoured from a loopback socket
|
|
* carrying the startup secret, so it can only have come from the admin nginx: a proxy host or
|
|
* stream forwarding to the backend port is also a loopback peer, but can't know the secret.
|
|
* The realip-resolved client address is deliberately not used: nginx trusts X-Real-IP from
|
|
* private ranges, so a client on the LAN or Docker network could pick a new address, and a fresh
|
|
* bucket, on every request. Behind another proxy (including an NPM proxy host in front of the
|
|
* admin UI) this resolves to that proxy's address.
|
|
*
|
|
* @param {Object} req
|
|
* @returns {String}
|
|
*/
|
|
export const getClientIp = (req) => {
|
|
const socketAddr = req.socket?.remoteAddress || "unknown";
|
|
const isLoopback = socketAddr === "127.0.0.1" || socketAddr === "::1" || socketAddr === "::ffff:127.0.0.1";
|
|
const trusted = isLoopback && hasPeerSecret(req.headers["x-npm-peer-secret"]);
|
|
const peerAddr = trusted ? req.headers["x-npm-peer-addr"]?.trim() : null;
|
|
return peerAddr || socketAddr;
|
|
};
|
|
|
|
const makeRoom = (store, now) => {
|
|
for (const [k, r] of store.entries()) {
|
|
if (now - r.startTime > r.windowMs) {
|
|
store.delete(k);
|
|
}
|
|
}
|
|
// Maps iterate in insertion order, so this drops the oldest windows first
|
|
for (const k of store.keys()) {
|
|
if (store.size < MAX_STORE_SIZE) {
|
|
break;
|
|
}
|
|
store.delete(k);
|
|
}
|
|
};
|
|
|
|
const boundedKey = (key) =>
|
|
key.length > MAX_KEY_LENGTH ? `sha256:${crypto.createHash("sha256").update(key).digest("hex")}` : key;
|
|
|
|
/**
|
|
* @param {Object} [options]
|
|
* @param {Number} [options.windowMs]
|
|
* @param {Number} [options.max]
|
|
* @param {String} [options.message]
|
|
* @param {Boolean} [options.skipSuccessfulRequests]
|
|
* @param {Function} [options.keyGenerator] (req, clientIp) => String. Defaults to the client IP.
|
|
* Including the target account in the key stops every
|
|
* user being locked out when many clients share an IP
|
|
* (e.g. the admin UI behind another reverse proxy).
|
|
*/
|
|
export default function createRateLimiter(options = {}) {
|
|
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
|
|
const max = options.max || 10; // 10 requests default
|
|
const message = options.message || "Too many attempts from this IP, please try again later.";
|
|
const skipSuccessfulRequests = options.skipSuccessfulRequests !== false;
|
|
const keyGenerator = options.keyGenerator || ((_req, clientIp) => clientIp);
|
|
|
|
const store = new Map();
|
|
stores.add(store);
|
|
|
|
return (req, res, next) => {
|
|
// Bypass rate limiting in CI, test environments, or when explicitly disabled
|
|
if (
|
|
isCI() ||
|
|
process.env.CI === "true" ||
|
|
process.env.NODE_ENV === "test" ||
|
|
process.env.DISABLE_RATE_LIMIT === "true"
|
|
) {
|
|
return next();
|
|
}
|
|
|
|
const key = boundedKey(String(keyGenerator(req, getClientIp(req))));
|
|
|
|
const now = Date.now();
|
|
let record = store.get(key);
|
|
|
|
if (!record || now - record.startTime > windowMs) {
|
|
record = {
|
|
count: 0,
|
|
startTime: now,
|
|
windowMs,
|
|
};
|
|
if (!store.has(key) && store.size >= MAX_STORE_SIZE) {
|
|
makeRoom(store, now);
|
|
}
|
|
store.set(key, record);
|
|
}
|
|
|
|
record.count++;
|
|
|
|
if (skipSuccessfulRequests) {
|
|
const currentRecord = record;
|
|
res.on("finish", () => {
|
|
if (res.statusCode < 400 && currentRecord.count > 0) {
|
|
currentRecord.count--;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (record.count > max) {
|
|
res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000));
|
|
return res.status(429).json({
|
|
error: {
|
|
code: 429,
|
|
message,
|
|
},
|
|
});
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|