fix(auth): keep the single-user login throttle at 10, widen only the multi-user pre-gate

Single-user mode has no per-username bucket, so its per-IP bucket is the
only limit on guesses against the one password, and behind a tunnel every
client shares 127.0.0.1. Put it back on AUTH_FAILURE_MAX (10).

AUTH_IP_FAILURE_MAX (50) now gates only the multi-user per-IP pre-gate,
which runs before the scrypt verify and so refuses even a correct password;
the per-username bucket still caps each account at 10.

Move the headroom test out of the single-user suite and add a multi-user
test that sprays wrong passwords across usernames (9 each) and expects 401
for the first 50, then 429 for the 51st and for a correct password.

Update the docs that still described 10 per IP for every mode, and resync
the plugin mirror of the codeman skill.
This commit is contained in:
Aamer Akhter committed 2026-10-22 21:21:49 -04:00
1 parent ebb9e00de9
commit 175e3b8320
12 files changed
+61 -35

No files matched your search

+7 -5
View File
@@ -23,15 +23,17 @@ export const MAX_AUTH_SESSIONS = 100;
/**
* Max failed auth attempts per bucket before 429 rejection. Applies to the
* per-username lockout and the hook-secret bucket.
* single-user per-IP bucket, the multi-user per-username lockout and the
* hook-secret bucket.
*/
export const AUTH_FAILURE_MAX = 10;
/**
* Max failed login attempts per client IP before 429 rejection. Wider than
* AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel, an automated client) can
* carry many users' or retries' failures; the per-username lockout above still
* caps guesses against any one account.
* Max failed login attempts per client IP for the multi-user pre-gate, which
* runs before the scrypt verify and so refuses even a correct password once
* reached. Wider than AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel) can
* carry many users' failures; the per-username bucket still caps guesses against
* each account at AUTH_FAILURE_MAX. Single-user mode does not use this.
*/
export const AUTH_IP_FAILURE_MAX = 50;
+4 -2
View File
@@ -448,7 +448,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
// Rate limit only requests that failed to authenticate on this attempt.
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_IP_FAILURE_MAX) {
if (failures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
@@ -572,7 +572,9 @@ function registerMultiUserAuthHook(
// 2. Basic Auth against the user store (scrypt verify).
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
// per-username bucket here; see below).
// per-username bucket here; see below). Wider than AUTH_FAILURE_MAX: it refuses
// even a correct password, so behind a shared egress IP (NAT, a tunnel) a narrow
// cap would let a few wrong guesses lock everyone out.
const ipFail = authFailures.get(clientIp) ?? 0;
if (ipFail >= AUTH_IP_FAILURE_MAX) {
sendAuthRateLimit(reply, authFailures, clientIp);