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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user