"""In-process per-IP sliding-window rate limiter (security audit 0026, H1). The auth verify endpoints (`/auth/otc/verify`, `/auth/passcode/verify`) had no per-IP brake, so an attacker could fan out guesses against a target identity bounded only by bcrypt cost. This module is the brake. It is deliberately tiny: §4.2 says the app is a single process with a colocated SQLite file, so an in-memory dict of `key -> deque[timestamps]` is sufficient and needs no shared store. State resets on restart, which fails *open* for a brief window — acceptable because the per-email OTC lockout (`otc_verify_state`) and the passcode lockout both persist in the database and carry the durable guarantee; this limiter is the anti-fan-out layer on top. Chosen over a per-identity lockout *as the primary control* because a per-IP window throttles the attacker without letting them grief a victim by locking that victim's account (the known downside of identity lockouts). Both layers run together. """ from __future__ import annotations import threading import time from collections import defaultdict, deque class SlidingWindowLimiter: """Allow at most `max_events` per `window_seconds` per key. `allow(key)` records an event and returns True if the key is still within budget, False if it has exceeded it. Timestamps use a monotonic clock so the limiter is immune to wall-clock jumps. """ def __init__(self, max_events: int, window_seconds: float) -> None: self.max_events = max_events self.window_seconds = window_seconds self._events: dict[str, deque[float]] = defaultdict(deque) self._lock = threading.Lock() def allow(self, key: str) -> bool: now = time.monotonic() cutoff = now - self.window_seconds with self._lock: q = self._events[key] while q and q[0] < cutoff: q.popleft() if len(q) >= self.max_events: return False q.append(now) # Opportunistic cleanup so idle keys don't accumulate forever. if not q: self._events.pop(key, None) return True def reset(self, key: str) -> None: """Drop a key's window — e.g. after a successful sign-in so a legitimate user who fat-fingered a few times isn't throttled.""" with self._lock: self._events.pop(key, None) # Module-level limiters shared across requests (one process, so module # state is the natural home). Tunables are intentionally generous enough # not to bother a human retyping a code, tight enough to kill fan-out: # * verify: 10 attempts / 5 min / IP across the auth verify surfaces. # * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate; # this is defense in depth against a solved-challenge replay loop). verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300) otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300) # /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3). # It's a legitimate Login-flow affordance, so the budget is generous — # enough for a human typing emails, tight enough to stop bulk scraping. check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300) def _reset_all_for_tests() -> None: """Clear every module-level limiter's window. Test support only — the limiters are process-global singletons, so without a per-test reset one test's requests bleed into the next and later tests trip the budget (429). Not called in production.""" for lim in (verify_limiter, otc_request_limiter, check_limiter): with lim._lock: lim._events.clear() def client_key(request) -> str: """Best-effort client identity for limiting. Behind nginx the app is started with `--forwarded-allow-ips 127.0.0.1`, so `request.client.host` reflects the real client IP via Uvicorn's ProxyHeaders handling.""" client = getattr(request, "client", None) return client.host if client and client.host else "unknown"