Files
chicken_babies_site/tests/test_security_headers.py
Phillip Tarrant f9f90d408e chore: phase 6 hardening — CSP/HSTS, access log, docker, backup, CI
Ships the cross-cutting hardening set:

- SecurityHeadersMiddleware: per-request nonce-based CSP, HSTS
  (production only), Referrer-Policy, Permissions-Policy,
  X-Content-Type-Options, frame-ancestors 'none', form-action 'self'.
- AccessLogMiddleware: one http_request INFO event per request
  (method/path/status/duration_ms/ip/ua). Skips /healthz, redacts
  /admin/auth/consume/<token> paths, logs 500 + re-raises on
  downstream exceptions.
- Public base.html inline nav-toggle script gets a nonce so it
  passes strict CSP without relaxing to 'unsafe-inline'.
- Dockerfile: non-root app user (uid/gid 10001) + stdlib-only
  HEALTHCHECK against /healthz.
- scripts/backup.sh: sqlite3 .backup + tar data/media with
  14-entry retention; host-side cron install documented.
- .gitea/workflows/build-image.yml: on push to master /
  workflow_dispatch, builds and publishes
  git.sneakygeek.net/ptarrant/chicken_babies_site:latest +
  sha-<short>, with GIT_COMMIT_SHA threaded as a build-arg so
  /healthz keeps reporting the right commit in deployed images.
- 8 new tests (security headers + access log).

Pre-existing dev failures (logo asset rename + RESEND env
pollution) remain unchanged; verified not Phase 6 regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:38:23 -05:00

125 lines
4.5 KiB
Python

"""Tests for :class:`app.middleware.SecurityHeadersMiddleware`.
The middleware is wired into the real application by
:func:`app.main.create_app`, so these tests exercise it end-to-end via
the module-level ``app`` instance — no bespoke app / harness.
Coverage:
- Every expected response header is present on the homepage.
- The CSP header carries a ``nonce-`` directive whose value changes
across requests (so a replayed page can't re-authorize an
attacker-controlled script).
- HSTS is **absent** in development — otherwise a browser would refuse
to load ``http://127.0.0.1:8000`` after a single successful test run.
"""
from __future__ import annotations
import re
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="module")
def client() -> TestClient:
"""Module-scoped TestClient so each test reuses the running app."""
return TestClient(app)
# CSP nonces are emitted as ``'nonce-<base64url>'``; capture the payload.
_NONCE_RE = re.compile(r"'nonce-([A-Za-z0-9_\-]+)'")
def _extract_nonce(csp_header: str) -> str:
"""Pull the ``nonce-<value>`` token out of a CSP header string."""
match = _NONCE_RE.search(csp_header)
assert match, f"CSP header is missing a nonce directive: {csp_header!r}"
return match.group(1)
def test_csp_header_present_and_carries_nonce(client: TestClient) -> None:
"""The homepage returns a CSP header with a nonce-<N> directive."""
response = client.get("/")
assert response.status_code == 200
csp = response.headers.get("Content-Security-Policy")
assert csp is not None, "CSP header missing on /"
# Sanity: the directives we care about are all present.
assert "default-src 'self'" in csp
assert "frame-ancestors 'none'" in csp
assert "form-action 'self'" in csp
assert "https://js.hcaptcha.com" in csp # hCaptcha allowlist intact.
nonce = _extract_nonce(csp)
assert len(nonce) >= 16, f"nonce looks too short: {nonce!r}"
def test_csp_nonce_changes_across_requests(client: TestClient) -> None:
"""Two requests must see two different nonces.
A reused nonce would let an attacker who captured one response
authorize a script in a later response — hence the per-request
fresh mint in :class:`SecurityHeadersMiddleware.dispatch`.
"""
first = client.get("/")
second = client.get("/")
assert first.status_code == 200
assert second.status_code == 200
nonce_a = _extract_nonce(first.headers["Content-Security-Policy"])
nonce_b = _extract_nonce(second.headers["Content-Security-Policy"])
assert nonce_a != nonce_b, "CSP nonce must be fresh per response"
def test_hsts_absent_in_development(client: TestClient) -> None:
"""HSTS is production-only; the dev TestClient must not see it."""
response = client.get("/")
assert response.status_code == 200
assert "Strict-Transport-Security" not in response.headers
def test_other_security_headers_present(client: TestClient) -> None:
"""Spot-check every non-CSP security header we commit to."""
response = client.get("/")
assert response.status_code == 200
expected = {
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cross-Origin-Opener-Policy": "same-origin",
}
for name, value in expected.items():
assert response.headers.get(name) == value, (
f"{name}: expected {value!r}, "
f"got {response.headers.get(name)!r}"
)
# Permissions-Policy is multi-valued; just assert the key disables
# we care about are present rather than pinning the full string.
perm = response.headers.get("Permissions-Policy", "")
for directive in ("camera=()", "geolocation=()", "microphone=()"):
assert directive in perm, (
f"Permissions-Policy missing {directive!r}; got {perm!r}"
)
def test_nonce_appears_in_rendered_template(client: TestClient) -> None:
"""The per-request CSP nonce must be reachable from Jinja templates.
The public base template stamps the nonce onto the inline nav-toggle
<script>; we verify the exact value from the CSP header appears in
the HTML body so the browser will actually run that script.
"""
response = client.get("/")
assert response.status_code == 200
nonce = _extract_nonce(response.headers["Content-Security-Policy"])
assert f'nonce="{nonce}"' in response.text, (
"inline <script> was not stamped with the CSP nonce; "
"browsers will refuse to execute it"
)