diff --git a/apps/api/src/routes/localAuth.ts b/apps/api/src/routes/localAuth.ts index c86c45d..624e71e 100644 --- a/apps/api/src/routes/localAuth.ts +++ b/apps/api/src/routes/localAuth.ts @@ -135,9 +135,14 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook) // Increment the counter even on 429 so continued brute-force accumulates toward lockout. if (live && live.count >= RATE_WINDOW_FAILURES && now < live.lockedUntil) { live.count += 1; - live.lockedUntil = now + RATE_WINDOW_SECS * 1000; + // WR-06: do NOT extend lockedUntil here. This request was itself REJECTED by the window; + // re-arming the cooldown on every blocked attempt let an attacker who keeps hammering the + // endpoint slide the window forward forever, so a legitimate user behind the same identity + // could never get back in even after pausing. The window stays anchored to when it was + // first armed (in the failure path below); it expires on schedule regardless of rejected + // traffic. The lockout (423) still triggers once the failure count crosses the threshold. live.lockedOut = live.count >= LOCKOUT_FAILURES; - if (live.lockedOut) live.lockedAt = now; + if (live.lockedOut && live.lockedAt === 0) live.lockedAt = now; loginAttempts.set(key, live); if (live.lockedOut) { return c.json({ error: 'Account locked' }, 423);