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:
2026-04-22 07:38:23 -05:00
parent f4dc6c266d
commit f9f90d408e
12 changed files with 756 additions and 8 deletions

View File

@@ -57,6 +57,7 @@ from app import __version__
from app.config import get_settings
from app.db import build_engine, run_migrations
from app.logging_config import configure_logging
from app.middleware import AccessLogMiddleware, SecurityHeadersMiddleware
from app.models.seed import run_seed
from app.routes.admin import router as admin_router
from app.routes.admin_cms import router as admin_cms_router
@@ -289,10 +290,29 @@ def create_app() -> FastAPI:
application.state.admin_pages_service = admin_pages_service
application.state.media_service = media_service
# --- Phase 6 middlewares -----------------------------------------------
# Install order matters: FastAPI.add_middleware wraps each new entry
# AROUND the existing stack, so the LAST one added runs first on the
# request. We want:
# - SecurityHeadersMiddleware innermost (stamps nonce on
# request.state BEFORE the route runs, writes headers on the
# response just before it exits the handler layer) -> added FIRST.
# - AccessLogMiddleware outermost (times the entire stack,
# including security-headers + CSRF cookie work) -> added LAST.
application.add_middleware(
SecurityHeadersMiddleware,
production=(settings.app_env == "production"),
)
# CSRF cookie middleware — narrow to admin GETs; everything else
# passes through untouched so public routes are unaffected.
application.add_middleware(CSRFCookieMiddleware, csrf_service=csrf_service)
# Access log — added last so it wraps (and thus times) every other
# middleware. Skips /healthz to keep compose healthcheck noise out
# of the log; redacts /admin/auth/consume/<token> paths.
application.add_middleware(AccessLogMiddleware)
# SlowAPI limiter + exception handler. The limiter is a module-level
# singleton in app.services.rate_limit (because @limiter.limit has
# to be applied at endpoint-definition time, before include_router).