diff --git a/apps/api/src/routes/localAuth.ts b/apps/api/src/routes/localAuth.ts index 624e71e..68bd47e 100644 --- a/apps/api/src/routes/localAuth.ts +++ b/apps/api/src/routes/localAuth.ts @@ -97,6 +97,29 @@ export function resetLoginAttempts(username: string): void { loginAttempts.delete(username.trim()); } +/** + * IN-03: evict stale rate-limit entries to bound the in-memory map size. + * + * An entry is stale (and safe to drop) when it is neither inside an active rate-limit + * window nor inside an active lockout TTL: + * - locked-out entries expire LOCKOUT_TTL_MS after lockedAt + * - non-locked entries expire once their lockedUntil window has passed + * + * Called opportunistically at the top of each login request. Dropping an entry is + * behaviourally identical to the entry having naturally expired, so eviction never + * weakens the brute-force defense — it only reclaims memory for identities no longer + * under active rate-limiting. + */ +function evictStaleLoginAttempts(now: number): void { + for (const [k, v] of loginAttempts) { + const lockoutExpired = v.lockedOut ? now - v.lockedAt >= LOCKOUT_TTL_MS : true; + const windowExpired = now >= v.lockedUntil; + if (lockoutExpired && windowExpired) { + loginAttempts.delete(k); + } + } +} + // Pre-computed dummy hash used to run verifyPassword on unknown-username paths // (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2). // WR-03: hashPassword is now async. Kick off the computation once at module load and keep @@ -115,6 +138,9 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook) const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts const now = Date.now(); + // IN-03: opportunistically reclaim memory from entries that are no longer actively + // rate-limited or locked out. Cheap at household scale; bounds the map under input churn. + evictStaleLoginAttempts(now); const attempt = loginAttempts.get(key); // 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires