Bound the rate limiter's bucket dict (B-56)
_buckets is keyed by strings the caller chooses — any username, and (before B-54) any IP — and only ever grew: decay_seconds aged a bucket's counter but never removed the entry, so hammering login with random usernames was an unbounded memory leak. A bucket is "spent" once its lockout has expired *and* its failure count would decay to zero on the next failure anyway — at which point keeping it and dropping it are indistinguishable, which is what makes eviction safe. Those are swept on record_failure (at most once every 60s) and on the read path, so a key that's merely being probed never leaves an entry behind. That alone holds the dict at the size of the genuinely active attack surface. _MAX_BUCKETS = 50_000 is the backstop for a burst faster than the sweep interval, when nothing has had time to expire. Over it, the entries closest to expiry go first: what an attacker gets from a successful flood is the loss of the shallowest, nearly-over lockouts, never the deep ones actually holding an attack back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,17 +40,6 @@ remains the last prerequisite for running unattended.
|
||||
|
||||
## High — security
|
||||
|
||||
### B-56 — `RateLimiter._buckets` is never pruned
|
||||
|
||||
`app/auth/rate_limit.py:37`.
|
||||
|
||||
The dict grows without bound, keyed by attacker-chosen strings (arbitrary
|
||||
usernames, and — via B-54 — arbitrary IPs). `decay_seconds` ages a bucket's
|
||||
*counter* but never removes the entry.
|
||||
|
||||
Fix: evict entries whose last failure is older than `decay_seconds` (opportunistically
|
||||
on `record_failure`, or on a periodic sweep), and cap the dict size.
|
||||
|
||||
### B-57 — username matching is case-sensitive while the login throttle key is not
|
||||
|
||||
`app/auth/routes.py:126` (`body.username.lower()`) vs `:132`
|
||||
|
||||
Reference in New Issue
Block a user