Files
Jamie Curnow 5b7e34eb67 Fix second round of review findings from PR #6029
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.
2026-11-09 23:39:26 +10:00

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();
};
}