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