Files
chicken_babies_site/app/middleware/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

123 lines
4.8 KiB
Python

"""Security-headers middleware.
Installs a strict-ish set of security response headers on every outgoing
response — notably a nonce-based ``Content-Security-Policy`` that locks
inline ``<script>`` usage to per-request tokens. Templates read the
nonce from ``request.state.csp_nonce`` and stamp it onto whichever
``<script>`` blocks need to run.
Modelled on :class:`app.main.CSRFCookieMiddleware`: the constructor
takes the ASGI app plus any configuration it needs by keyword;
``dispatch`` is async and always returns the downstream response.
Header set (matches ``docs/security.md`` + Phase 6 brief):
- ``Content-Security-Policy`` — nonce-based ``script-src`` that also
allowlists hCaptcha; ``frame-ancestors 'none'`` replaces the legacy
``X-Frame-Options: DENY``.
- ``Strict-Transport-Security`` — **only in production**; the dev
server is reached over plain HTTP at ``http://127.0.0.1:8000`` and
HSTS would make that session permanently HTTPS-only for the browser.
- ``X-Content-Type-Options: nosniff``
- ``Referrer-Policy: strict-origin-when-cross-origin``
- ``Permissions-Policy`` — disable every sensor/device API we do not
use (defense in depth for any future supply-chain compromise).
- ``Cross-Origin-Opener-Policy: same-origin``
The nonce is generated fresh per request (``secrets.token_urlsafe(16)``
→ 128 bits, URL-safe) and stored on ``request.state.csp_nonce`` before
the downstream handler runs, so Jinja templates can read it via the
implicit ``request`` context variable.
"""
from __future__ import annotations
import secrets
from fastapi import Request
from fastapi.responses import Response
from starlette.middleware.base import BaseHTTPMiddleware
# --- CSP template ---------------------------------------------------------
# Note the ``{nonce}`` placeholder: we format per-request so the token is
# unique to each response. ``style-src 'unsafe-inline'`` is a known
# compromise — we don't emit our own inline styles, but third-party
# widgets (hCaptcha) and some HTML attribute defaults want it. Locking
# this down is a future task.
_CSP_TEMPLATE: str = (
"default-src 'self'; "
"script-src 'self' 'nonce-{nonce}' https://js.hcaptcha.com https://*.hcaptcha.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self' https://*.hcaptcha.com; "
"frame-src https://*.hcaptcha.com https://newassets.hcaptcha.com; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# Static response headers that do not depend on per-request state. Kept
# as a module-level dict so we pay the allocation cost once at import
# time and just iterate on every response.
_STATIC_HEADERS: dict[str, str] = {
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": (
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), "
"magnetometer=(), microphone=(), payment=(), usb=()"
),
"Cross-Origin-Opener-Policy": "same-origin",
}
# HSTS is production-only. One year, subdomains included; no ``preload``
# directive (that requires submitting the apex to the HSTS preload list,
# which is a separate operational step).
_HSTS_VALUE: str = "max-age=31536000; includeSubDomains"
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Attach CSP + friends to every response.
Parameters
----------
app:
The ASGI application. ``BaseHTTPMiddleware`` stores this.
production:
When ``True`` the middleware also emits ``Strict-Transport-Security``.
Passed explicitly (rather than read from :mod:`app.config` here)
so the middleware remains unit-testable without loading settings.
"""
def __init__(self, app, *, production: bool) -> None:
"""Store the production flag; the app handle is owned by the base class."""
super().__init__(app)
self._production: bool = production
async def dispatch(self, request: Request, call_next):
"""Mint a nonce, run the downstream handler, stamp the headers.
The nonce is attached to ``request.state`` *before* ``call_next``
so any template rendered by the route handler can read it. The
CSP header itself is added after the response is produced so it
rides on every path (HTML, JSON, static bypass, error pages).
"""
# 128 bits of entropy, URL-safe base64 — plenty for CSP nonce use.
nonce: str = secrets.token_urlsafe(16)
request.state.csp_nonce = nonce
response: Response = await call_next(request)
response.headers["Content-Security-Policy"] = _CSP_TEMPLATE.format(
nonce=nonce
)
for key, value in _STATIC_HEADERS.items():
response.headers[key] = value
if self._production:
response.headers["Strict-Transport-Security"] = _HSTS_VALUE
return response