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:
2026-08-03 22:22:51 +02:00
co-authored by Claude Opus 5
parent 9c7befe595
commit ab65728bdc
4 changed files with 146 additions and 14 deletions
-11
View File
@@ -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`