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