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:
2026-09-07 23:08:46 -04:00
parent 894637108c
commit 42a4222949
32 changed files with 1439 additions and 2 deletions
+96
View File
@@ -14,6 +14,15 @@ the ONLY anonymous content is the shared chats (plus the login/infra
endpoints the gate itself needs) — anonymous chat / suggestions /
document content now 401 ``authentication required``.
Rate limiting (phase 81, audit SEC-03): the sign-in failure counter is
PROCESS state (in-memory per IP), and TestClient's
``request.client.host`` is the fixed value ``"testclient"`` — so every
client in this module (including ``_admin_client``'s separate
TestClient) shares ONE limiter entry. The ``clean_rate_limit`` autouse
fixture resets that entry around every test so the deliberate 429 pins
can't poison the other tests (and the table TRUNCATEs can't help: the
counter is not row state).
Requires: podman compose up -d db
"""
from __future__ import annotations
@@ -28,6 +37,9 @@ from sqlalchemy import text
from test_chat_api import FakeRagLLM, _stream_chat
from app.api import chat as chat_api
from app.api.auth import TOO_MANY_DETAIL
from app.core import rate_limit
from app.core.rate_limit import WINDOW_SECONDS
from app.main import app as fastapi_app
from app.models import Document
from app.rag.importer import import_sources
@@ -37,6 +49,24 @@ FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
@pytest.fixture(autouse=True)
def clean_rate_limit() -> Iterator[None]:
"""Reset the in-memory sign-in-failure counter around every test.
TestClient's ``request.client.host`` is the fixed value
``"testclient"``, so every client in this module — including the
``_admin_client`` helper's separate TestClient — shares ONE limiter
entry, and the counter is process state the table TRUNCATEs cannot
clear. Without this reset, the deliberate 429 tests below (which
block the shared IP on purpose) would make every later test in the
process hit the throttled 429 path instead of the 401/204 contract
it pins.
"""
rate_limit.reset("testclient")
yield
rate_limit.reset("testclient")
@pytest.fixture(autouse=True)
def clean_tables(db) -> Iterator[None]:
"""Docs + steering + query log + tokens are global state: reset
@@ -425,3 +455,69 @@ def test_admin_chat_still_streams(client: TestClient, db, seeded_kb: FakeRagLLM)
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is False
assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
# ---------- phase 81: rate-limited failed sign-ins (audit SEC-03) ----------
def test_eleventh_failed_login_429_with_retry_after(client: TestClient) -> None:
"""10 wrong passwords → 401 (the phase-16 contract, unchanged
detail); the 11th attempt is the FIRST 429 — one generic detail + a
``Retry-After`` header carrying whole-window seconds."""
for _ in range(10):
r = client.post("/api/login", json={"password": "nope"})
assert r.status_code == 401
assert r.json() == {"detail": "invalid password"}
r = client.post("/api/login", json={"password": "nope"})
assert r.status_code == 429
assert r.json()["detail"] == TOO_MANY_DETAIL
retry_after = int(r.headers["Retry-After"])
assert retry_after > 0
assert retry_after <= WINDOW_SECONDS # the whole-window wait, never more
def test_correct_password_while_blocked_still_429(client: TestClient) -> None:
"""An exhausted window stays exhausted until it slides: the pre-check
fires BEFORE the password is compared, so even a CORRECT password
gets the 429 while blocked (success only resets a CLEAN counter —
this is the intended semantics, pinned)."""
for _ in range(10):
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 429
assert r.json()["detail"] == TOO_MANY_DETAIL
assert "bor_session" not in client.cookies # not signed in while throttled
def test_success_on_clean_counter_resets_it(client: TestClient) -> None:
"""9 failures + 1 success (204) → the counter is back to 0: the next
9 failures are all plain 401s (a fresh 10 would be needed to block
again). A fat-fingered owner is not poisoned by earlier misses."""
for _ in range(9):
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
for _ in range(9):
r = client.post("/api/login", json={"password": "nope"})
assert r.status_code == 401
assert r.json() == {"detail": "invalid password"}
def test_login_and_token_failures_share_the_counter(client: TestClient) -> None:
"""The counter is SHARED by both routes (a token-spraying attack must
not dodge the window by alternating routes): 5 login failures + 5
token failures exhaust it, so the 11th attempt of EITHER shape — even
with a VALID token — is a 429."""
# Admin + token FIRST: the successful admin login resets the shared
# counter, so it must not happen after failures have accumulated.
admin = _admin_client()
_token_id, token = _create_token(admin)
for _ in range(5):
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
for _ in range(5):
r = client.post("/api/token-auth", json={"token": "bor_" + "0" * 32})
assert r.status_code == 401
assert r.json() == {"detail": "invalid token"}
r = client.post("/api/token-auth", json={"token": token})
assert r.status_code == 429
assert r.json()["detail"] == TOO_MANY_DETAIL
assert int(r.headers["Retry-After"]) > 0
+149
View File
@@ -0,0 +1,149 @@
"""Unit: the sliding-window failed sign-in limiter (phase 81, task 01).
Covers ``app.core.rate_limit`` — the per-client-IP sliding window that
both login routes share (the audit SEC-03 fix). Pins, in the task's
order:
* 9 failures → still allowed (``remaining_wait == 0``);
* the 10th failure → blocked (``remaining_wait > 0``, ``<= WINDOW_SECONDS``);
* independent IPs never share counters;
* window slide/expiry — a failure older than ``WINDOW_SECONDS`` stops
counting (both pre-seeded module state and a fake monotonic clock);
* ``remaining_wait`` is the WHOLE-window wait until the OLDEST in-window
failure expires (the ``Retry-After`` contract), not a per-failure age;
* ``reset`` (a success on either route) unblocks immediately;
* an unknown IP never blocks;
* FAIL-OPEN — a corrupted, non-deque state entry and a broken clock
both degrade to "allow": ``remaining_wait`` returns 0 and
``record_failure`` drops the count without raising. A limiter bug
must never lock the owner out.
"""
from __future__ import annotations
import time
from collections import deque
from collections.abc import Iterator
import pytest
from app.core import rate_limit as rl
@pytest.fixture(autouse=True)
def _clean_state() -> Iterator[None]:
"""The limiter is module-global: no counter may leak between tests."""
rl._failures.clear()
yield
rl._failures.clear()
def test_nine_failures_still_allowed() -> None:
"""Below the threshold: 9 in-window failures → no wait (the 10th
failure is the one that crosses the bar)."""
for _ in range(rl.MAX_FAILURES - 1):
rl.record_failure("ip")
assert rl.remaining_wait("ip") == 0
def test_tenth_failure_blocks_until_the_window_clears() -> None:
"""The 10th recorded failure crosses the threshold: the routes'
pre-check (the 11th attempt) sees a positive wait — bounded by the
whole window and integral for the ``Retry-After`` header."""
for _ in range(rl.MAX_FAILURES):
rl.record_failure("ip")
wait = rl.remaining_wait("ip")
assert isinstance(wait, int)
assert wait > 0
assert wait <= rl.WINDOW_SECONDS
def test_ips_are_independent() -> None:
"""One IP's failures never count toward another IP's window."""
for _ in range(rl.MAX_FAILURES):
rl.record_failure("ip")
assert rl.remaining_wait("ip") > 0
assert rl.remaining_wait("ip2") == 0
def test_failures_older_than_the_window_stop_counting_preseeded() -> None:
"""Reach into the module state: timestamps ``WINDOW_SECONDS + 1`` in
the past have slid out — 10 such 'failures' leave the IP unblocked."""
now = time.monotonic()
rl._failures["ip"] = deque([now - rl.WINDOW_SECONDS - 1.0] * rl.MAX_FAILURES)
assert rl.remaining_wait("ip") == 0
def test_window_slides_and_wait_is_measured_from_the_oldest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Fake monotonic clock, 10 failures one second apart (t = 1000..1009):
* the wait is the WHOLE-window seconds until the OLDEST in-window
failure expires — ``ceil(900 - 9) == 891`` at t = 1009, not a
per-failure age;
* the wait counts down with the oldest (300 at t = 1600, clamped to
1 at t = 1899);
* the block lifts the moment the oldest failure slides out (t =
1900): 9 failures are then still in the window, yet the count is
back below the threshold.
"""
clock = {"t": 1000.0}
monkeypatch.setattr(time, "monotonic", lambda: clock["t"])
for i in range(rl.MAX_FAILURES):
clock["t"] = 1000.0 + i # failures at 1000, 1001, ..., 1009
rl.record_failure("ip")
assert rl.remaining_wait("ip") == 891 # ceil(900 - 9)
clock["t"] = 1600.0
assert rl.remaining_wait("ip") == 300 # oldest is 600 s old
clock["t"] = 1899.0
assert rl.remaining_wait("ip") == 1 # ceil(900 - 899), clamped >= 1
clock["t"] = 1900.0
assert rl.remaining_wait("ip") == 0 # oldest just expired → 9 left
def test_reset_unblocks_immediately() -> None:
"""A success on either route clears the IP's counter: the owner who
fat-fingered is not poisoned by their own earlier misses."""
for _ in range(rl.MAX_FAILURES):
rl.record_failure("ip")
assert rl.remaining_wait("ip") > 0
rl.reset("ip")
assert rl.remaining_wait("ip") == 0
def test_unknown_ip_never_blocks() -> None:
"""No failures recorded → no wait (the routes' pre-check fast path)."""
assert rl.remaining_wait("never-seen") == 0
def test_fail_open_on_corrupt_state_entry() -> None:
"""A non-deque entry in the module state (corruption):
``remaining_wait`` must return 0 (never block) and ``record_failure``
must drop the count WITHOUT raising (fail-open — a limiter bug never
locks the owner out). The IP stays unblocked afterwards."""
rl._failures["bad"] = object() # type: ignore[assignment]
assert rl.remaining_wait("bad") == 0
rl.record_failure("bad") # must not raise
assert rl.remaining_wait("bad") == 0
def test_fail_open_on_broken_clock(monkeypatch: pytest.MonkeyPatch) -> None:
"""A clock anomaly (monotonic raising) is equally fail-open: 0 wait
and a silent drop — the login route itself must never 500 because
of the limiter."""
def broken_clock() -> float:
raise ValueError("clock skew")
monkeypatch.setattr(time, "monotonic", broken_clock)
assert rl.remaining_wait("any") == 0
rl.record_failure("any") # must not raise
assert rl.remaining_wait("any") == 0
def test_reset_unknown_ip_is_a_noop() -> None:
"""No entry to drop: ``reset`` must not raise (the routes call it on
every success unconditionally)."""
rl.reset("ghost")
assert rl.remaining_wait("ghost") == 0