Files
brain-of-reese/.agents/phases/complete/82_security_headers/00_phase.md
T
ducoterra e29d68d9f0 phase: 82_security_headers
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`.
2026-09-07 23:54:41 -04:00

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, textContent for data — audit-verified at every innerHTML site), 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.
  • nosniff is 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' — no script-src/style-src/connect-src entries (they inherit default-src 'self'); no 'unsafe-inline' anywhere (verified unnecessary); no report-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 wraps send and writes headers on http.response.start only; no body drain, no buffering, so the SSE chat stream and every other response pass through byte-identical (the explicit contrast with CachingMiddleware, which does buffer page bodies).
  • A3 — outermost position: registered in app/main.py AFTER configure_caching(app) (last add_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. No Referrer-Policy, no Permissions-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):
    CSP = "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)
    
    Module docstring: the audit basis (SEC-04), the No-CDN verification that makes '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, unlike CachingMiddleware's page buffering).
  • app/main.py — import + app.add_middleware(SecurityHeadersMiddleware) placed immediately AFTER the configure_caching(app) call (with a one-line comment: outermost on purpose — headers on every response incl. static 404s). Nothing else in main.py moves.
  • 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

  1. 01_headers_middleware.md — app/core/security_headers.py + unit tests (incl. the SSE-stream passthrough pin).
  2. 02_registration_and_integration.md — app/main.py registration + integration tests on real app responses.
  3. 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 no send wrapping error.
  • Integration — tests/integration/test_security_headers.py (new): against the real app (the conftest client): 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 existing tests/integration/test_caching_revalidation.py fixture 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 navigation response.headers carry all three (CSP exact); (2) the page actually works under the CSP — no CSP violations: collect console messages 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 (computed background-color of body is not rgba(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-cov green in isolation — headers on real page loads + zero CSP violations + the page painted.
  • uv run pytest green; uv run pytest --cov=app --cov-report=term-missing >90%; uv run ruff check . && uv run pyright clean.
  • No template/JS/pyproject.toml/uv.lock diff; git diff --stat limited to the two new files, app/main.py, the three test files, phase files.
  • One atomic --no-gpg-sign commit (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 send for headers only; any body change is a phase failure (the chat E2E suites are the tripwire).