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`.
9.2 KiB
Phase 82 — Security headers on every response: strict CSP, no-framing, nosniff
Source: .agents/remediation_plan.md SEC-04 (security audit 2026-09-07, severity Medium — "No security headers (CSP, X-Frame-Options / frame-ancestors)")
Story: n/a (security hardening — audit-derived, no user story)
Context: no response in the app carries Content-Security-Policy, X-Frame-Options, or X-Content-Type-Options (checked app/main.py, app/core/caching.py, all templates — none do). The frontend is No-CDN and was verified for this phase: no inline <style> blocks, no style="…" attributes, no onclick/on* handlers, no javascript: URLs, no external hosts in any frontend/*.html or the JS-injected markup (inline <svg> elements are CSP-legal; the two el.style.… CSSOM writes in app.js are not CSP-blocked). app/main.py is the single app-assembly point (middleware added in order: SessionMiddleware → configure_caching's CachingMiddleware → static mount last). app/core/caching.py is the existing transport-layer middleware precedent (but it BUFFERS page bodies — this phase's middleware must not).
Objective
Every response — pages, static assets, API JSON, the SSE chat stream, even 404s — carries a strict same-origin CSP (frame-ancestors 'none' included → clickjacking closed), X-Frame-Options: DENY, and X-Content-Type-Options: nosniff, added by a header-only pure-ASGI middleware that never touches a body (the SSE stream passes through byte-identical).
Audit basis (read this, not the chat)
- Every page (including the admin sign-in and the admin-only views) can be embedded in a third-party page's iframe: a malicious LAN page can overlay the admin UI and trick the signed-in owner into clicking Sync / Revoke / Delete-source actions (audit PoC:
<iframe src="http://<server>:8000/git-sources.html">with a benign-looking overlay).frame-ancestors 'none'(in the CSP) +X-Frame-Options: DENY(legacy fallback) close it. - The app serves no CSP today: the codebase is XSS-clean (escape-first renderer,
textContentfor data — audit-verified at everyinnerHTMLsite), but the CSP is the standard second line of defense against any future regression. It is cheap here precisely because of the No-CDN + no-inline-markup verification above:default-src 'self'is sufficient without any'unsafe-inline'— a genuinely strict policy. nosniffis the one-line belt-and-braces against MIME-confusion on the static mount.
Owner decisions (chat, 2026-09-07 — recorded per AGENTS.md rule 3)
- A1 — the exact policy:
Content-Security-Policy: default-src 'self'; base-uri 'none'; frame-ancestors 'none'— noscript-src/style-src/connect-srcentries (they inheritdefault-src 'self'); no'unsafe-inline'anywhere (verified unnecessary); noreport-uri/report-to(no collector in the homelab — a report would just vanish). - A2 — header-only, pure ASGI: a small pure-ASGI middleware (not
BaseHTTPMiddleware) — it wrapssendand writes headers onhttp.response.startonly; no body drain, no buffering, so the SSE chat stream and every other response pass through byte-identical (the explicit contrast withCachingMiddleware, which does buffer page bodies). - A3 — outermost position: registered in
app/main.pyAFTERconfigure_caching(app)(lastadd_middleware= outermost), so even responses built by the static catch-all / 404 handler carry the headers. - A4 — the three headers only: CSP +
X-Frame-Options: DENY+X-Content-Type-Options: nosniff. NoReferrer-Policy, noPermissions-Policy, no HSTS (the app serves plain homelab HTTP — HSTS would be wrong here; TLS is the documented non-goal).
Design (shared by all tasks — the executor reads this, not the chat)
app/core/security_headers.py(new):Module docstring: the audit basis (SEC-04), the No-CDN verification that makesCSP = "default-src 'self'; base-uri 'none'; frame-ancestors 'none'" class SecurityHeadersMiddleware: """Header-only, pure-ASGI — never reads or buffers a body (SSE-safe).""" def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return async def send_wrapper(message): if message["type"] == "http.response.start": headers = MutableHeaders(scope=message) headers["Content-Security-Policy"] = CSP headers["X-Frame-Options"] = "DENY" headers["X-Content-Type-Options"] = "nosniff" await send(message) await self.app(scope, receive, send_wrapper)'unsafe-inline'unnecessary (re-verify if inline scripts/styles are ever introduced — the policy would break the page loudly, which is the feature), the pure-ASGI rationale (body untouched — SSE stream byte-identical, unlikeCachingMiddleware's page buffering).app/main.py— import +app.add_middleware(SecurityHeadersMiddleware)placed immediately AFTER theconfigure_caching(app)call (with a one-line comment: outermost on purpose — headers on every response incl. static 404s). Nothing else inmain.pymoves.- Not touched:
app/core/caching.py, any template, any JS, the API layer, the No-CDN surface.
Dependencies
— (none; standalone transport hardening — wraps the whole app, no route or template change)
Tasks
01_headers_middleware.md—app/core/security_headers.py+ unit tests (incl. the SSE-stream passthrough pin).02_registration_and_integration.md—app/main.pyregistration + integration tests on real app responses.03_e2e_and_commit.md— the dedicated Playwright suite + full gate + atomic commit.
Testing & Quality
- Unit —
tests/unit/test_security_headers.py(new): a minimal ASGI test-app wrapped by the middleware → a plain response carries all three headers with the exact values; a 404-shaped response carries them too; a streaming response (a generator yielding two chunks,media_type="text/event-stream") — the body is consumed byte-identical (both chunks, in order) AND the headers are present (the SSE passthrough pin); a non-http scope (e.g.websocket) passes through with the app called and nosendwrapping error. - Integration —
tests/integration/test_security_headers.py(new): against the real app (theconftestclient):GET /(a page) → all three headers, CSP exactly the A1 string;GET /api/health→ all three;GET /assets/styles.css→ all three;GET /nope(404) → all three. (If the existingtests/integration/test_caching_revalidation.pyfixture pattern for the app client exists, reuse it — same setup, no new fixtures.) - E2E (mandatory, dedicated suite — this phase changes what the browser receives):
tests/e2e/test_security_headers.py— (1) navigate to the chat page: the navigationresponse.headerscarry all three (CSP exact); (2) the page actually works under the CSP — no CSP violations: collectconsolemessages during load + one chat turn is NOT required (mock LLM is not needed for a header test) — assert zero console messages matching/Content Security Policy/and that the stylesheet applied (computedbackground-colorofbodyis notrgba(0, 0, 0, 0)— the page painted, not a CSP-broken white page); (3) navigate to/sources.html(a second page) → headers present, no CSP violations. Run in isolation per AGENTS.md rule 9. - Coverage: >90% on
app/— the new module is small and unit-exercised;app/main.py's registration is covered by every integration/E2E boot.
Completion Criteria
curl -sI localhost:8000/ | grep -iE "content-security-policy|x-frame-options|x-content-type"shows all three with the exact A1 CSP value (manual check, output kept in the session log); same for/api/health,/assets/styles.css, and a 404 path.- The SSE chat stream is byte-identical through the middleware: the unit streaming pin + the existing chat E2E suites (e.g.
tests/e2e/test_chat_rag.py) green — a body-touching regression would break them. uv run pytest tests/e2e/test_security_headers.py -v --no-covgreen in isolation — headers on real page loads + zero CSP violations + the page painted.uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missing>90%;uv run ruff check . && uv run pyrightclean.- No template/JS/
pyproject.toml/uv.lockdiff;git diff --statlimited to the two new files,app/main.py, the three test files, phase files. - One atomic
--no-gpg-signcommit (e.g.fix(security): add CSP, X-Frame-Options and nosniff headers to every response); phase dir moved to.agents/phases/complete/.
Locked decisions
- No-CDN contract intact — the CSP is
default-src 'self'because the audit verified zero external hosts and zero inline scripts/styles; introducing either later breaks pages loudly (the policy does the enforcing). - No HSTS / Referrer-Policy / Permissions-Policy (owner A4) — plain homelab HTTP is the documented posture; the three headers above are the scope.
- SSE byte-identity — the middleware wraps
sendfor headers only; any body change is a phase failure (the chat E2E suites are the tripwire).