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
+128
View File
@@ -0,0 +1,128 @@
"""Phase 82 E2E (Playwright): the security headers reach a real browser and
the strict CSP does not break the page.
Story: n/a — security hardening, audit SEC-04 (``.agents/remediation_plan.md``).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_security_headers.py -v --no-cov
No LLM interaction: the page load IS the contract. The suite never touches
the chat stream and never depends on the aipi endpoint — the shared
``app_server`` fixture's deterministic LOCAL mock LLM is simply unused.
Test → task mapping (phase 82, task 03):
1. ``test_chat_page_navigation_headers_and_csp_clean`` — a real ``page.goto``
of the chat shell (``/``): the navigation response carries all three
headers (CSP exactly the A1 string), the page painted under the CSP
(the dark-tech canvas applied → the stylesheet loaded), the shell's
``#main`` booted, and zero console CSP violations.
2. ``test_sources_page_navigation_headers_and_csp_clean`` — the same header
+ no-violation contract for the second page, ``/sources.html``.
Paint-check note (vs. the task text): the task suggested asserting the
``body`` background-color is not transparent, but the pinned design
contract (phase 08, ``test_dark_tech_theme.py``) puts the visible canvas on
``<html>`` (``html { background: var(--bg) }``) and keeps ``<body>``
transparent on purpose (the z-index:-1 grid layer must show). The
stylesheet-loaded-under-the-CSP contract is therefore pinned on the
``<html>`` canvas color — the same value phase 08 pins.
"""
from __future__ import annotations
import re
from playwright.sync_api import ConsoleMessage, Page, expect
#: The exact owner-approved policy (phase 82, decision A1).
CSP = "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
#: Chromium reports CSP denials to the console with this phrasing
#: ("Refused to … because it violates the following Content Security
#: Policy directive …" / "The Content Security Policy directive …
#: prevents execution …").
_CSP_CONSOLE = re.compile(r"Content Security Policy", re.IGNORECASE)
#: The dark-tech canvas — ``frontend/assets/styles.css`` ``--bg: #0f0a0a``,
#: pinned by phase 08 (``test_dark_tech_theme.py``).
CANVAS_BG = "rgb(15, 10, 10)"
def _attach_csp_watch(page: Page) -> list[str]:
"""Record every console message that mentions a CSP denial.
Attached BEFORE navigation (the contract — denials of the main
document's resources are logged while it loads). Returns the live
list; assert it is empty after the load.
"""
violations: list[str] = []
def _on_console(msg: ConsoleMessage) -> None:
if _CSP_CONSOLE.search(msg.text):
violations.append(msg.text)
page.on("console", _on_console)
return violations
def _assert_navigation_headers(page: Page, url: str) -> None:
"""A real navigation: the response carries all three headers, CSP exact."""
response = page.goto(url)
assert response is not None, f"no navigation response for {url}"
assert response.status == 200, f"{url} → {response.status}"
headers = response.headers # lowercase keys
assert headers.get("content-security-policy") == CSP, (
f"{url}: CSP is {headers.get('content-security-policy')!r}, "
f"expected the exact A1 string {CSP!r}"
)
assert headers.get("x-frame-options") == "DENY", (
f"{url}: X-Frame-Options is {headers.get('x-frame-options')!r}"
)
assert headers.get("x-content-type-options") == "nosniff", (
f"{url}: X-Content-Type-Options is {headers.get('x-content-type-options')!r}"
)
def _assert_chat_page_painted(page: Page) -> None:
"""The page painted under the CSP — styles loaded, shell booted.
The visible canvas is ``<html>`` (see the module docstring): its
computed color can only be the dark-tech palette if ``/assets/*.css``
was fetched and applied under ``default-src 'self'`` — a CSP-broken
document would be a white/default canvas without ``#main`` styled.
"""
bg = page.evaluate(
"() => getComputedStyle(document.documentElement).backgroundColor"
)
assert bg == CANVAS_BG, (
f"dark-tech canvas not applied under the CSP (stylesheet not loaded?): {bg}"
)
expect(page.locator("#main")).to_be_attached()
box = page.locator("#main").bounding_box()
assert box is not None and box["width"] > 0 and box["height"] > 0, (
"shell <main> is present but invisible/empty — the page did not boot"
)
def test_chat_page_navigation_headers_and_csp_clean(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: real navigation of the chat shell → three headers (CSP exact),
painted page, zero CSP violations on the console."""
violations = _attach_csp_watch(page)
_assert_navigation_headers(page, app_url + "/")
_assert_chat_page_painted(page)
# Console delivery is async — let pending messages settle before the
# zero-violation gate (the load event already fired).
page.wait_for_timeout(250)
assert violations == [], f"CSP violations on the chat page: {violations}"
def test_sources_page_navigation_headers_and_csp_clean(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC2: the second page (Sources) — same three headers, no CSP
violations."""
violations = _attach_csp_watch(page)
_assert_navigation_headers(page, app_url + "/sources.html")
page.wait_for_timeout(250)
assert violations == [], f"CSP violations on the sources page: {violations}"