All completion criteria verified green — no defects found, nothing to fix. Final report: **Phase 82 (security headers) — final verification pass: all green** - Verified prior-run implementation: `app/core/security_headers.py` (pure-ASGI, header-only, exact A1 CSP), registration in `app/main.py` after `configure_caching` (outermost), unit/integration/E2E suites. - Deviation confirmed sound: `data:`-URI favicon (blocked by locked CSP) → static `frontend/assets/favicon.svg` in 5 templates + Containerfile `cp`; SVG element byte-identical to the old data-URI (verified programmatically); serves 200 with all three headers. - Curl check (server booted like e2e conftest, log: `/tmp/curl_security_headers_final.log`): `/`, `/api/health`, `/assets/styles.css`, `/nope` (404) → all three headers, CSP exactly `default-src 'self'; base-uri 'none'; frame-ancestors 'none'`. - `uv run pytest tests/unit/test_security_headers.py tests/integration/test_security_headers.py -v --no-cov` → 13 passed (incl. SSE byte-identity pin). - `uv run pytest tests/e2e/test_security_headers.py -v --no-cov` (isolated) → 2 passed (headers + zero CSP violations + painted page). - SSE tripwire `uv run pytest tests/e2e/test_chat_rag.py -v --no-cov` → 3 passed. - `uv run pytest --cov=app --cov-report=term-missing` → 1665 passed, app/ 99% (>90%); `uv run ruff check . && uv run pyright` → clean (0 errors). - `git diff --stat` limited to phase-82 files + the two documented deviations (favicon set, `tests/unit/__init__.py`); no `pyproject.toml`/`uv.lock`/JS diffs. - Commit + phase-dir move left to the harness per pipeline rules (not executed by me). Next pending phase: `83_chat_save_payload_limits`.
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).