Lockout -> server/auth.py + per-IP failure budget (XFF trusted only behind the proxy); unit tests

This commit is contained in:
jozamudi committed 2026-09-16 01:31:40 +00:00
1 parent 826ee9165d
commit b70ef403a9
4 files changed
+127 -1

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
"""auth.py — login throttling: per-username lockout + per-IP failure budget.
Pure logic, no app imports, so it unit-tests without booting the app
(tests/test_auth.py). The 0.5s wrong-password sleep is exactly why app.py's
login handler must stay a sync def: on the event loop it would stall every
stream in flight; on the threadpool it only holds that one request.
Two in-memory layers (single process — same assumption as everything else):
* per-username: 5 fails -> 15 min lockout for that name (was: the only
layer, so spraying fails across many names bypassed it entirely)
* per-IP: IP_FAILS fails inside IP_WINDOW -> that IP is refused for the
rest of the window. Defaults are deliberately loose (15/300s): they
exist to stop username spraying, not to punish typos.
"""
import os
import time
from . import users
LOCK_FAILS = 5
LOCK_SECONDS = 900
IP_FAILS = int(os.environ.get("ANIWEB_IP_FAILS", "15"))
IP_WINDOW = float(os.environ.get("ANIWEB_IP_WINDOW", "300"))
_lockout: dict = {} # user -> {"fails": int, "until": float}
_ip_fails: dict = {} # ip -> [failure ts] (newest last, capped)
def client_ip(request) -> str:
"""Client IP. X-Forwarded-For is trusted ONLY when ANIWEB_TRUST_XFF=1
(run.sh sets it because production is always behind the local HTTPS
proxy); unconditional trust would let any client forge the header and
dodge the per-IP budget."""
if os.environ.get("ANIWEB_TRUST_XFF") == "1":
xff = request.headers.get("x-forwarded-for", "")
if xff:
return xff.split(",", 1)[0].strip()
return request.client.host if request.client else "?"
def ip_fail(ip: str) -> None:
_ip_fails[ip] = (_ip_fails.get(ip, []) + [time.time()])[-50:]
def ip_locked(ip: str) -> bool:
now = time.time()
ts = [t for t in _ip_fails.get(ip, ()) if now - t < IP_WINDOW]
if ts:
_ip_fails[ip] = ts
return len(ts) >= IP_FAILS
def lock(user: str, pw: str, ip: str) -> str:
"""-> "ok" | "bad" | "locked" (per-username lockout or per-IP budget)"""
if ip_locked(ip):
return "locked"
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0})
if time.time() < lo["until"]:
return "locked"
if users.verify(user, pw):
lo.update(fails=0, until=0.0)
return "ok"
lo["fails"] += 1
if lo["fails"] >= LOCK_FAILS:
lo.update(fails=0, until=time.time() + LOCK_SECONDS)
time.sleep(0.5) # slow down brute force (sync handler -> threadpool)
ip_fail(ip)
return "bad"