Files
plm-lottery/app/auth/rate_limit.py
T
davideandClaude Opus 5 0aac73e557 Bound registrations with a per-IP quota instead of failure backoff (B-58)
The registration throttle called record_failure on every attempt, successful
ones included. Five legitimate signups from one shared or NAT address locked the
sixth real user out for up to 600s, doubling from there — while an attacker
sidestepped the limiter entirely through B-54. Failure backoff is the wrong
instrument here: nothing about creating an account is a failed guess at a
secret, so the only people it reliably punished were the honest ones.

RollingQuota says what was actually meant: 5 accounts per IP per hour, in a
rolling window. The caller over it waits exactly until the oldest of the five
ages out — an accurate Retry-After, and waiting never makes the next wait
longer. It is recorded only once an account exists, so attempts that create
nothing (a taken username, a validation error) leave the quota untouched, and
checked before the Argon2 hash, so an IP out of quota costs nothing to refuse.

Bounded like the failure limiter (B-56): the keys are caller-chosen, so the dict
gets both a sweep and a hard cap, evicting keys with room left in their quota
before full ones.

Also fixes the inline comment that cited B-31 (the resubscribe finding) where it
meant B-33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:32:20 +02:00

211 lines
9.3 KiB
Python

import time
from dataclasses import dataclass
# B-56: bounds on _buckets, which is keyed by strings the caller chooses.
# 50k entries is a few MB at ~100 bytes each — far more than any real deployment's
# active attacker set, and small enough that filling it isn't a memory attack.
_MAX_BUCKETS = 50_000
# How often record_failure sweeps out spent entries. Cheap (one pass over a dict
# that the sweep itself keeps small) and off the request's critical path in the
# normal case, since a successful login records no failure at all.
_SWEEP_INTERVAL_SECONDS = 60.0
@dataclass
class _Bucket:
failures: int = 0
locked_until: float = 0.0
last_failure_at: float = 0.0
class RateLimiter:
"""In-process failed-attempt throttle with exponential backoff, keyed by an
arbitrary string (username, IP...). Single-process-only, like UserLocks
(app/tx/locks.py) — an accepted MVP constraint; a multi-worker deployment
would need a shared store (Redis) instead (B-33).
Brute-forcing a login here isn't a spammy client to be capped at N req/s —
it's an attempt to withdraw someone else's funds — so failures are
penalized with a delay that doubles each time past `threshold` free
attempts, rather than a flat rate cap. `decay_seconds` ages a bucket back
to zero once failures stop, so a shared/NAT IP isn't punished forever for
someone else's earlier mistakes.
"""
def __init__(
self,
threshold: int = 5,
base_delay: float = 2.0,
max_delay: float = 300.0,
decay_seconds: float = 900.0,
max_buckets: int = _MAX_BUCKETS,
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
) -> None:
self._threshold = threshold
self._base_delay = base_delay
self._max_delay = max_delay
self._decay_seconds = decay_seconds
self._max_buckets = max_buckets
self._sweep_interval_seconds = sweep_interval_seconds
self._buckets: dict[str, _Bucket] = {}
self._last_sweep_at = time.monotonic()
def _is_spent(self, bucket: _Bucket, now: float) -> bool:
"""Nothing left to remember: the lockout has expired *and* the failure count
would decay to zero on the next failure anyway. Dropping such a bucket is
indistinguishable from keeping it — which is what makes eviction safe."""
return bucket.locked_until <= now and now - bucket.last_failure_at > self._decay_seconds
def _prune(self, now: float) -> None:
"""B-56: the dict was keyed by attacker-chosen strings (any username, and via
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.
Spent buckets go first, and they carry no information, so that alone keeps
the dict at the size of the genuinely active attack surface. The hard cap
below is the backstop for a burst faster than the sweep interval: it evicts
the entries closest to expiry, i.e. the ones whose loss buys an attacker the
least — never the freshest lockouts, which are the ones actually holding an
attack back."""
for key in [k for k, b in self._buckets.items() if self._is_spent(b, now)]:
del self._buckets[key]
self._last_sweep_at = now
excess = len(self._buckets) - self._max_buckets
if excess > 0:
by_expiry = sorted(
self._buckets.items(), key=lambda item: (item[1].locked_until, item[1].last_failure_at)
)
for key, _ in by_expiry[:excess]:
del self._buckets[key]
def retry_after(self, key: str) -> float:
bucket = self._buckets.get(key)
if bucket is None:
return 0.0
now = time.monotonic()
if self._is_spent(bucket, now):
# Self-cleaning read path: a key that's merely being probed never
# accumulates an entry that outlives its own usefulness.
del self._buckets[key]
return 0.0
remaining = bucket.locked_until - now
return remaining if remaining > 0 else 0.0
def record_failure(self, key: str) -> None:
now = time.monotonic()
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._buckets) > self._max_buckets:
self._prune(now)
bucket = self._buckets.setdefault(key, _Bucket())
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
bucket.failures = 0
bucket.failures += 1
bucket.last_failure_at = now
if bucket.failures >= self._threshold:
delay = min(self._max_delay, self._base_delay * 2 ** (bucket.failures - self._threshold))
bucket.locked_until = now + delay
def record_success(self, key: str) -> None:
self._buckets.pop(key, None)
class RollingQuota:
"""How many times a key may do something in a rolling window — as opposed to
RateLimiter above, which punishes *failures* with a growing delay.
B-58: registration was throttled with the failure limiter, and recorded a
"failure" on every attempt, successful ones included. Five legitimate signups
from one shared or NAT address locked the sixth real user out for up to 600s,
with the backoff doubling from there — while an attacker sidestepped the whole
thing through B-54. The intent (bound how many accounts one source can create)
is right; failure backoff is the wrong instrument for it, since nothing here is
a failed guess at a secret. A quota says exactly what is meant: this many
accounts per source per window, and the answer to the one over it is "not yet",
with an accurate wait rather than a punishment that grows.
"""
def __init__(
self,
limit: int,
window_seconds: float,
max_keys: int = _MAX_BUCKETS,
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
) -> None:
self._limit = limit
self._window_seconds = window_seconds
self._max_keys = max_keys
self._sweep_interval_seconds = sweep_interval_seconds
self._events: dict[str, list[float]] = {}
self._last_sweep_at = time.monotonic()
def _live_events(self, key: str, now: float) -> list[float]:
"""The key's events still inside the window, pruned in place."""
events = self._events.get(key)
if events is None:
return []
cutoff = now - self._window_seconds
while events and events[0] <= cutoff:
events.pop(0)
if not events:
del self._events[key]
return events
def _prune(self, now: float) -> None:
# Same bound as RateLimiter (B-56): the keys are caller-chosen, so the dict
# needs both a sweep and a hard cap. Eviction order is likewise "closest to
# leaving the window first" — dropping a key with room left in its quota
# changes nothing, dropping a full one hands out free accounts.
for key in list(self._events):
self._live_events(key, now)
self._last_sweep_at = now
excess = len(self._events) - self._max_keys
if excess > 0:
by_oldest = sorted(self._events.items(), key=lambda item: item[1][-1])
for key, _ in by_oldest[:excess]:
del self._events[key]
def retry_after(self, key: str) -> float:
"""Seconds until this key may act again — 0 while it is under quota."""
now = time.monotonic()
events = self._live_events(key, now)
if len(events) < self._limit:
return 0.0
return events[0] + self._window_seconds - now
def record(self, key: str) -> None:
"""Counts one *completed* action. Attempts that create nothing (a taken
username, a validation error) deliberately don't consume the quota — the
limit is on accounts that exist, not on requests."""
now = time.monotonic()
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._events) > self._max_keys:
self._prune(now)
self._events.setdefault(key, []).append(now)
_REGISTRATIONS_PER_IP = 5
_REGISTRATION_WINDOW_SECONDS = 3600.0
class AuthRateLimiters:
"""The three throttles B-33 needs, bundled so they can live on `app.state`
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
A module global would persist for the lifetime of the process — fine in
production (one app instance), but wrong in the test suite, where every
test builds its own FastAPI app against a fresh in-memory DB and expects a
clean slate; a shared global would leak failure counts between unrelated
tests. Per-`app.state` state gets a fresh instance per app automatically.
"""
def __init__(self) -> None:
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
# B-58: a quota, not failure backoff — 5 accounts per IP per hour. The one
# over it waits only until the oldest of the five ages out, and a busy NAT
# is slowed rather than locked out for progressively longer.
self.register_ip = RollingQuota(
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
)