fix(security): apply per-IP rate limiting to credential endpoints

The five authRateLimiter registrations were inert for the same reason the
general one was -- registered below the error handler. Auth endpoints have
never had an IP limit; the 5-attempt behaviour QA observed is the per-account
lockout in authSecurity.js, which is a different mechanism and is untouched.

They could not simply be activated: app.use('/api/auth', ...) is a prefix, so
a 5-per-window budget would have covered GET /api/auth/session and
POST /api/auth/password-strength, which the frontend calls far more than five
times per window. That locks users out.

The real surface was enumerated by loading the routers and walking
router.stack rather than grepping, which showed two of the five registrations
pointed at routes that do not exist: adminAuth.js has no /login (admin login
is POST /api/auth/admin/login) and there is no /api/gallery/:slug/verify
(gallery verify is POST /api/auth/gallery/verify).

Now limited, on exact method+path: admin login, admin MFA verify, gallery
password verify, share-login, client PIN, setup verify-token, setup admin,
customer login, customer password-reset. Deliberately unlimited: session
checks, password-strength, logouts, authenticated change-password, the SSO
round-trip (a 429 on the callback breaks login from shared corporate IPs),
and one-time invite/accept-invite links.

Two choices carry the design. skipSuccessfulRequests means only failed
attempts spend budget, which is what makes 5-per-IP survivable behind NAT --
ten guests on one venue wifi all typing the correct gallery password consume
nothing -- and means a legitimate admin cannot be locked out by their own
success. And the limiter keeps its own rateLimit() instance, hence its own
store and its own per-IP bucket, with the general gate's auth exemption left
in place: sharing a counter is exactly the lockout described above.

Patterns are case-insensitive because Express's case-sensitive routing is off
by default, so POST /api/auth/admin/LOGIN reaches the login handler and a
case-sensitive pattern would have been a free bypass.

max is now read per request, so the Settings UI's rate_limit_auth_max_requests
applies without a restart, matching the general limiter.

Tests prove both directions: each credential endpoint 429s on attempt 6 with
the response shape the four login pages already branch on, each benign
endpoint still returns 200 after 40 calls, the two buckets are independent in
both directions, and 30 consecutive successful logins consume no budget.

Refs testplan REPORT.md, rate-limiter gap.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:11 +02:00
parent 30ac4140af
commit 50e8ed6e58
5 changed files with 426 additions and 23 deletions
+4 -4
View File
@@ -39,10 +39,10 @@ const EXEMPT_PREFIXES = [
// request. Five ordinary unauthenticated calls — the branding and settings
// fetches a login page makes before anyone types a password — would therefore
// 429 the login itself for a whole window. Giving them a real per-IP budget
// means giving them their own bucket, which is what the (currently unreachable)
// dedicated authRateLimiter is for; until that is wired up they keep today's
// behaviour, where brute force is bounded by the per-account lockout in
// utils/authSecurity.js.
// means giving them their own bucket, which is what authRateLimitGate now does:
// a separate limiter instance with its own store, matching exact method + path
// and counting only failed attempts. They stay exempt HERE so the two budgets
// never share a counter — that sharing is the whole failure mode above.
//
// The pattern is deliberately identical to rateLimitService's own isAuthEndpoint
// check, so the exempt set is exactly the set that would get the 5 budget.
@@ -0,0 +1,92 @@
/**
* Per-IP rate limit for the credential-verification endpoints.
*
* Same boot-order problem as apiRateLimitGate, same fix: the auth limiter is
* built asynchronously from app_settings, so it was registered from inside
* initializeRateLimiters() — which runs after every router, the /api 404
* handler and the error handler are already on the stack. Express dispatches
* in registration order, so those five app.use() calls landed below everything
* that answers a request and never executed. Auth endpoints have therefore
* never had an IP-based limit; brute force was bounded only by the per-account
* lockout in utils/authSecurity.js, which is per-identifier, not per-IP, and so
* does not bound spraying one password across many usernames or many galleries.
*
* This gate is a stable function registered at the right depth immediately and
* resolving the limiter per request, so the limit no longer depends on boot
* timing. It stays a pass-through until initializeRateLimiters() resolves.
*
* Two things it deliberately does NOT do:
*
* 1. It does not match on a prefix. The old registrations used
* app.use('/api/auth', authRateLimiter), and /api/auth is the mount point of
* the whole auth router — so the 5-per-window budget would also have covered
* GET /api/auth/session and POST /api/auth/password-strength, both of which
* the frontend calls far more than five times per window. Activating them as
* written would have locked legitimate users out. The table below is exact
* method + exact path.
*
* 2. It does not share the general limiter's bucket. Each express-rate-limit
* instance owns a MemoryStore, so authRateLimiter counts into its own per-IP
* bucket and the ordinary /api traffic a login page makes (branding,
* settings) cannot exhaust the login budget.
*
* The limiter is configured with skipSuccessfulRequests, so only *failed*
* attempts consume the budget. That is what makes a 5-per-window budget safe
* behind NAT: ten guests on one venue wifi who all type the right gallery
* password consume nothing.
*
* Register with app.use(gate) and NOT app.use('/api', gate) — Express strips
* the mount path from req.url, and these patterns are written against the full
* path.
*/
// Exact method + path. Anchored, and case-insensitive because Express's
// "case sensitive routing" setting is off by default: POST /api/auth/admin/LOGIN
// reaches the login handler, so a case-sensitive pattern would be a free bypass.
// The optional trailing slash is there for the same reason.
const CREDENTIAL_ENDPOINTS = [
// Admin password, and the second factor that completes the same login.
{ method: 'POST', path: /^\/api\/auth\/admin\/login\/?$/i },
{ method: 'POST', path: /^\/api\/auth\/admin\/login\/mfa\/?$/i },
// Gallery password, client PIN, share-link token.
{ method: 'POST', path: /^\/api\/auth\/gallery\/verify\/?$/i },
{ method: 'POST', path: /^\/api\/auth\/gallery\/share-login\/?$/i },
{ method: 'POST', path: /^\/api\/auth\/gallery\/[^/]+\/client-login\/?$/i },
// First-run bootstrap: both of these take the setup token.
{ method: 'POST', path: /^\/api\/setup\/verify-token\/?$/i },
{ method: 'POST', path: /^\/api\/setup\/admin\/?$/i },
// Customer portal password, and the reset that replaces it.
{ method: 'POST', path: /^\/api\/customer\/auth\/login\/?$/i },
{ method: 'POST', path: /^\/api\/customer\/auth\/password-reset\/?$/i },
];
/**
* @param {import('express').Request} req
* @returns {boolean} true when the request is an attempt to prove a secret.
*/
function isCredentialEndpoint(req) {
return CREDENTIAL_ENDPOINTS.some(
(endpoint) => endpoint.method === req.method && endpoint.path.test(req.path)
);
}
/**
* @param {() => import('express').RequestHandler|undefined} getLimiter
* Reads the current auth rate limiter. Returns undefined until
* initializeRateLimiters() has resolved.
* @returns {import('express').RequestHandler}
*/
function createAuthRateLimitGate(getLimiter) {
return function authRateLimitGate(req, res, next) {
if (!isCredentialEndpoint(req)) return next();
const limiter = getLimiter();
// Boot window: the database is not up yet, so there is nothing to delegate
// to. Passing through is what every request did before this fix.
if (typeof limiter !== 'function') return next();
return limiter(req, res, next);
};
}
module.exports = { createAuthRateLimitGate, isCredentialEndpoint, CREDENTIAL_ENDPOINTS };
+18 -2
View File
@@ -207,13 +207,29 @@ async function createRateLimiter() {
/**
* Create auth-specific rate limiter
*
* This is a separate rateLimit() instance from createRateLimiter(), so it owns
* its own MemoryStore and therefore its own per-IP bucket. That separation is
* the point: the credential endpoints get a small budget of their own that the
* ordinary /api traffic a login page makes cannot exhaust.
*
* skipSuccessfulRequests means only failed attempts are counted, which is what
* makes a 5-per-window per-IP budget safe behind NAT — a room full of guests on
* one venue IP who all type the correct gallery password consume nothing.
*/
async function createAuthRateLimiter() {
const config = await getRateLimitSettings();
return rateLimit({
windowMs: config.windowMinutes * 60 * 1000,
max: config.authMaxRequests,
// Read per request, like the general limiter, so a change to
// rate_limit_auth_max_requests in admin Settings takes effect within the
// settings cache TTL instead of needing a restart.
max: async () => {
const currentConfig = await getRateLimitSettings();
return currentConfig.authMaxRequests;
},
skipSuccessfulRequests: true,
keyGenerator: (req) => req.ip,
skip: async () => {
const currentConfig = await getRateLimitSettings();