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`.
This commit is contained in:
2026-09-07 23:54:41 -04:00
parent 42a4222949
commit e29d68d9f0
29 changed files with 1032 additions and 5 deletions
@@ -0,0 +1,71 @@
# 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)**:
```python
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).