"""Sliding-window rate limiting for failed sign-ins (phase 81, audit SEC-03). Audit basis ----------- SEC-03 (security audit 2026-09-07, severity Medium): neither ``POST /api/login`` nor ``POST /api/token-auth`` throttled failed attempts — the password is compared in constant time (``secrets.compare_digest``), so an attacker who can reach ``:8000`` could guess at line rate (audit PoC: 10 000 sequential logins, all answered at line rate). This module is the fix: a per-client-IP sliding window over FAILED sign-in attempts only. Threshold (owner decision A2 — module constants, NOT ``BOR_`` settings: a security control with a fixed, tested default and no env surface to mistype): ``MAX_FAILURES = 10`` failed attempts per client IP within ``WINDOW_SECONDS = 900`` (15 min). The window slides — only failures within the last ``WINDOW_SECONDS`` count. The IP is blocked while its in-window failure count is >= ``MAX_FAILURES``: the 10th failure is recorded, and the 11th attempt is the first 429 (the routes' pre-check runs BEFORE the attempt). Shared counter -------------- The counter is SHARED by both login routes (``/api/login`` and ``/api/token-auth``): any auth failure from an IP counts, so a token-spraying attack cannot dodge the window by alternating routes, and a SUCCESS on either route clears that IP's counter (a legitimate owner who fat-fingers twice is not poisoned). The routes do the bookkeeping (pre-check / record / reset); this module only knows about failure timestamps. Client IP --------- Callers pass ``request.client.host`` — the DIRECT peer address (owner decision A4). Assumption locked in the phase: the app is served directly in the homelab, with NO reverse proxy in front. If a proxy is ever put in front, this module would need ``X-Forwarded-For`` handling (out of scope now). Fail-open --------- The limiter is a friction bump, not the security boundary (owner decision A1 — in-memory, per-process, stdlib-only; the counter is lost on restart, an accepted reset). A limiter bug must NEVER lock the owner out: any internal error (a corrupted state entry, a clock anomaly) makes ``remaining_wait`` return 0 (allow) and ``record_failure`` drop the count silently. Over-denial is the one outcome this module is forbidden to produce. """ from __future__ import annotations import math import threading import time from collections import deque #: An IP is blocked while it has at least this many failed sign-ins in #: the window (owner decision A2: 10 attempts per 15 minutes). MAX_FAILURES = 10 #: Sliding-window length in seconds (15 minutes). WINDOW_SECONDS = 900 #: Failed-attempt timestamps per client IP, oldest first. Module-global #: on purpose (A1: per-process state, no new service or table); the #: lock keeps the dict honest if the event loop is ever shared. _failures: dict[str, deque[float]] = {} _lock = threading.Lock() def _prune(dq: deque[float], now: float) -> None: """Drop failures that have slid out of the window (oldest first). A timestamp exactly ``WINDOW_SECONDS`` old is expired: the window holds the last ``WINDOW_SECONDS`` strictly. """ cutoff = now - WINDOW_SECONDS while dq and dq[0] <= cutoff: dq.popleft() def record_failure(client_ip: str) -> None: """Count one failed sign-in attempt from ``client_ip``. Appends ``time.monotonic()`` to the IP's deque and prunes expired entries. NEVER raises (fail-open): any internal error — including a corrupted, non-deque state entry — drops the count silently instead of risk blocking the owner. A corrupt entry is left in place (and treated as "not blocked" by ``remaining_wait``) until ``reset`` or a process restart clears it. """ try: with _lock: now = time.monotonic() dq = _failures.get(client_ip) if dq is None: dq = deque() _failures[client_ip] = dq elif not isinstance(dq, deque): return # corrupt state → drop this count (fail-open) dq.append(now) _prune(dq, now) except Exception: return # a limiter bug must never break the login route def remaining_wait(client_ip: str) -> int: """Seconds until ``client_ip`` may attempt again (0 = allowed now). Prunes expired failures; if fewer than ``MAX_FAILURES`` remain in the window the IP is allowed (0). Otherwise returns the WHOLE-window wait — ``ceil(WINDOW_SECONDS - (now - oldest))``, clamped to >= 1 — i.e. until the oldest counted failure expires (the value the routes put in ``Retry-After``; the window, not any single failure's age, governs). NEVER raises (fail-open → 0). """ try: with _lock: now = time.monotonic() dq = _failures.get(client_ip) if not isinstance(dq, deque) or not dq: return 0 # unknown IP or corrupt state → allow (fail-open) _prune(dq, now) if len(dq) < MAX_FAILURES: return 0 # includes the all-expired (empty) case wait = WINDOW_SECONDS - (now - dq[0]) return max(1, math.ceil(wait)) except Exception: return 0 # a limiter bug must never deny the owner def reset(client_ip: str) -> None: """Clear ``client_ip``'s failure count (called on a SUCCESS). A successful sign-in on either route unblocks the IP immediately — a legitimate owner who fat-fingers twice is not poisoned by their own earlier misses. Unknown IPs are a silent no-op. """ with _lock: _failures.pop(client_ip, None)