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:
@@ -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}"
|
||||
Reference in New Issue
Block a user