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>
This commit is contained in:
112
tests/test_access_log.py
Normal file
112
tests/test_access_log.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for :class:`app.middleware.AccessLogMiddleware`.
|
||||
|
||||
structlog in this project is wired through ``PrintLoggerFactory`` (see
|
||||
:mod:`app.logging_config`), which emits rendered log lines to stdout.
|
||||
We therefore capture via pytest's ``capsys`` fixture — the same pattern
|
||||
:mod:`tests.test_email_service` uses for its dev-fallback assertion.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Every request produces an ``http_request`` log line including the
|
||||
method, the path, and a status code.
|
||||
- ``/healthz`` is skipped (reduces compose healthcheck noise).
|
||||
- Magic-link consume paths have their token segment redacted to
|
||||
``<redacted>`` before the line is emitted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.logging_config import configure_logging
|
||||
from app.main import app
|
||||
|
||||
|
||||
# ConsoleRenderer wraps keys / values in ANSI colour escapes; strip them
|
||||
# so substring assertions can pattern-match on the semantic payload
|
||||
# rather than the rendering.
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Return ``text`` with ANSI terminal escapes removed."""
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(capsys: pytest.CaptureFixture[str]) -> TestClient:
|
||||
"""Return a TestClient with structlog reconfigured for capture.
|
||||
|
||||
The test harness may be left in any state by a previously run test;
|
||||
re-calling :func:`configure_logging` guarantees the ConsoleRenderer
|
||||
pipeline is active so ``capsys`` actually sees the log lines.
|
||||
|
||||
``capsys.readouterr()`` is called on entry so existing startup
|
||||
output (``app_started``, etc.) doesn't leak into the per-test
|
||||
assertions.
|
||||
"""
|
||||
configure_logging("development")
|
||||
capsys.readouterr() # drain any buffered prior output
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_request_emits_http_request_event(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A single GET to / produces an ``http_request`` log line."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" in out
|
||||
# The structured keys must also be present in the rendered line.
|
||||
assert "method=GET" in out
|
||||
assert "path=/" in out
|
||||
assert "status_code=200" in out
|
||||
|
||||
|
||||
def test_healthz_is_skipped(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""``/healthz`` requests do NOT produce an ``http_request`` event.
|
||||
|
||||
Compose / Docker health probes hit this path every 30s; logging
|
||||
every probe would drown out real traffic.
|
||||
"""
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" not in out, (
|
||||
"AccessLogMiddleware should skip /healthz, but emitted: "
|
||||
f"{out!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_path_is_redacted(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Magic-link consume tokens must never appear in the access log.
|
||||
|
||||
We hit the consume route with a synthetic token; the downstream
|
||||
handler will return 4xx (token doesn't match any DB row), but
|
||||
the middleware still runs — and its log line must carry the
|
||||
redacted path, not the raw token.
|
||||
"""
|
||||
fake_token = "super-secret-token-value-that-must-not-leak-1234567890"
|
||||
response = client.get(f"/admin/auth/consume/{fake_token}")
|
||||
# We don't care what status the router returns — only that the log
|
||||
# entry for this request redacts the token.
|
||||
assert response.status_code in (302, 303, 400, 401, 403, 404), (
|
||||
f"unexpected status {response.status_code}"
|
||||
)
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" in out
|
||||
assert "/admin/auth/consume/<redacted>" in out
|
||||
assert fake_token not in out, (
|
||||
"raw magic-link token appeared in access log output"
|
||||
)
|
||||
124
tests/test_security_headers.py
Normal file
124
tests/test_security_headers.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user