phase: 81_login_rate_limit
All gates verified. The phase is complete — everything was already implemented by prior task runs; this pass verified each completion criterion end-to-end and found no defects. **Phase 81 final verification report** (tasks 01–03 all present in `complete/`; no code changes needed this pass) - Verified: `app/core/rate_limit.py` (stdlib sliding window, fail-open) + both login routes' 429 pre-check/record/reset wiring + all unit/integration pins present (11th-429, blocked-success-still-429, clean-counter reset, shared counter, autouse `clean_rate_limit` fixture documented). - Live check (task 03): dev server + 11 rapid wrong logins → `401 ×10, 429`; 429 body carries `retry-after: 900` + generic detail; server restarted (per-process counter cleared by design). - `uv run pytest tests/unit/test_rate_limit.py -v --no-cov` → 10 passed - `uv run pytest --cov=app --cov-report=term-missing` → 1652 passed, **TOTAL 99%** (>90%; rate_limit.py 100%, auth.py 100%) - `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (isolation) → 3 passed - `uv run ruff check . && uv run pyright` → All checks passed / 0 errors, 0 warnings - Completion criteria: all met, except commit + phase-dir move — per harness rules I left all changes uncommitted in the working tree (harness commits atomically and moves the phase). - Diff scope: exactly `app/core/rate_limit.py`, `app/api/auth.py`, `tests/unit/test_rate_limit.py`, `tests/integration/test_auth_api.py` + phase files; `pyproject.toml` / `uv.lock` / `frontend/` untouched. - Deviations: none in code; commit/move deferred to harness as instructed. - Next pending phase: `82_security_headers`.
This commit is contained in:
+41
-2
@@ -2,11 +2,16 @@
|
||||
|
||||
* ``POST /api/login`` — 204 + signed session cookie on success; 401
|
||||
``invalid password`` on any mismatch (constant-time, one generic
|
||||
message, no session set).
|
||||
message, no session set); while the per-IP failure window is exhausted
|
||||
→ 429 + ``Retry-After``, one generic detail — audit SEC-03.
|
||||
* ``POST /api/token-auth`` — 204 + signed session cookie for a valid,
|
||||
unrevoked API token (PUBLIC — it is the token holders' login route);
|
||||
every failure shape (malformed / unknown / revoked / empty) is ONE
|
||||
generic 401 ``invalid token`` (no enumeration, the phase-16 pattern).
|
||||
generic 401 ``invalid token`` (no enumeration, the phase-16 pattern);
|
||||
while the per-IP failure window is exhausted → 429 + ``Retry-After``,
|
||||
one generic detail — audit SEC-03 (the 429 counter is SHARED with
|
||||
``/api/login``: any auth failure from an IP counts, a success on
|
||||
either route resets it).
|
||||
* ``POST /api/logout`` — 204; clears the session and expires the
|
||||
cookie (idempotent for anonymous callers — one logout wipes BOTH
|
||||
roles, the session is one dict).
|
||||
@@ -26,6 +31,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import rate_limit
|
||||
from app.core import tokens as token_service
|
||||
from app.core.auth import (
|
||||
ADMIN_SESSION_KEY,
|
||||
@@ -38,6 +44,10 @@ from app.core.auth import (
|
||||
from app.db import get_db
|
||||
from app.schemas import LoginRequest, TokenAuthRequest, WhoamiResponse
|
||||
|
||||
#: One generic 429 detail shared by BOTH throttled login routes (audit
|
||||
#: SEC-03): no enumeration between a login failure and a token failure.
|
||||
TOO_MANY_DETAIL = "too many failed sign-in attempts — try again later"
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
@@ -49,11 +59,27 @@ def login(payload: LoginRequest, request: Request) -> Response:
|
||||
12 h default lifetime). Failure: one generic 401 — the admin count is
|
||||
one, so there is nothing else to leak, and a wrong password must not
|
||||
set any session state.
|
||||
|
||||
Rate limiting (audit SEC-03): while the per-IP failure window is
|
||||
exhausted (10 misses in 15 min, shared with ``/api/token-auth``),
|
||||
the pre-check fires BEFORE the password is compared — 429 +
|
||||
``Retry-After``, one generic detail; even a correct password stays
|
||||
429 until the window slides. A success on a CLEAN counter resets it;
|
||||
a failure is recorded on the 401 path.
|
||||
"""
|
||||
ip = request.client.host if request.client is not None else "unknown"
|
||||
if (wait := rate_limit.remaining_wait(ip)) > 0:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=TOO_MANY_DETAIL,
|
||||
headers={"Retry-After": str(wait)},
|
||||
)
|
||||
settings = get_settings()
|
||||
if check_password(payload.password, settings.admin_password):
|
||||
sign_in(request.session)
|
||||
rate_limit.reset(ip)
|
||||
return Response(status_code=204)
|
||||
rate_limit.record_failure(ip)
|
||||
raise HTTPException(status_code=401, detail="invalid password")
|
||||
|
||||
|
||||
@@ -77,15 +103,28 @@ def token_auth(
|
||||
generic 401 ``invalid token``: the lookup is by hash (the service
|
||||
returns ``None`` for anything that is not an active row), so there
|
||||
is no enumeration surface (the phase-16 pattern).
|
||||
|
||||
Rate limiting (audit SEC-03): identical bookkeeping to ``/api/login``
|
||||
on the SAME per-IP counter — pre-check → 429 + ``Retry-After`` while
|
||||
the window is exhausted; failures recorded, a success resets.
|
||||
"""
|
||||
ip = request.client.host if request.client is not None else "unknown"
|
||||
if (wait := rate_limit.remaining_wait(ip)) > 0:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=TOO_MANY_DETAIL,
|
||||
headers={"Retry-After": str(wait)},
|
||||
)
|
||||
token = payload.token.strip()
|
||||
row = token_service.find_active_by_token(db, token) if token else None
|
||||
if row is None:
|
||||
rate_limit.record_failure(ip)
|
||||
raise HTTPException(status_code=401, detail="invalid token")
|
||||
token_service.mark_used(row)
|
||||
db.commit()
|
||||
request.session[USER_SESSION_KEY] = True
|
||||
request.session[USER_TOKEN_ID_KEY] = str(row.id)
|
||||
rate_limit.reset(ip)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user