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

@@ -246,3 +246,89 @@ Pre-requisites:
- [ ] `pytest -q tests/test_hcaptcha_service.py tests/test_contact_service.py tests/test_contact_routes.py` passes.
- [ ] `python -c "from app.main import app; print(len(app.routes))"` prints a count greater than the Phase 4 count (the new `POST /contact` adds one route).
---
## Phase 6 — Hardening + Deploy
Run a dev server alongside these checks:
```bash
source venv/bin/activate
uvicorn app.main:app --reload
```
### Security headers (`/`, `/contact`, `/admin/login`)
- [ ] `curl -sI http://127.0.0.1:8000/ | grep -i content-security-policy`
returns a policy containing `nonce-<...>`, `frame-ancestors 'none'`,
`form-action 'self'`, and `https://js.hcaptcha.com` in `script-src`.
- [ ] Two consecutive `curl -sI /` calls print **different** `nonce-<...>`
values — the middleware mints one per request.
- [ ] `X-Content-Type-Options: nosniff`, `Referrer-Policy:
strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy:
same-origin`, and a `Permissions-Policy` disabling camera /
geolocation / microphone are all present on the response.
- [ ] In development, `Strict-Transport-Security` is **absent** (so
localhost over HTTP keeps working).
- [ ] Set `APP_ENV=production` + all prod-required env vars and restart;
confirm `Strict-Transport-Security: max-age=31536000;
includeSubDomains` now appears.
- [ ] Browser devtools console on `/` shows the inline nav-toggle
script executes (the mobile hamburger still toggles the menu).
No "Refused to execute inline script" CSP violations appear.
- [ ] On `/contact`, the hCaptcha widget renders and its API script
loads (no CSP violations in the console).
- [ ] On any admin page, `admin_editor.js` loads and the live preview
still updates as you type.
### Access log (`/healthz` quiet, magic-link redacted)
- [ ] Normal page loads emit one `http_request` line with `method`,
`path`, `status_code`, `duration_ms`, `client_ip`, and
`user_agent` fields.
- [ ] `curl http://127.0.0.1:8000/healthz` does **not** produce an
`http_request` log line (the middleware skips `/healthz`).
- [ ] Request a magic link, then hit the consume URL in the browser.
The server log shows `path=/admin/auth/consume/<redacted>` —
the raw token never appears in stdout.
### Dockerfile hardening
- [ ] `docker compose build` succeeds.
- [ ] `docker compose up -d` brings the service to healthy; inspect
with `docker compose ps` — the STATUS column reads `(healthy)`
within ~30s of startup.
- [ ] `docker compose exec app id` reports `uid=10001(app) gid=10001(app)`.
- [ ] `docker inspect --format '{{.State.Health.Status}}' <container>`
returns `healthy`.
### Backup script
- [ ] `./scripts/backup.sh` exits 0 and produces
`data/backups/app-<ts>.db` and `data/backups/media-<ts>.tar.gz`.
- [ ] `sqlite3 data/backups/app-<ts>.db "SELECT COUNT(*) FROM posts"`
returns the same count as the live DB.
- [ ] Run the script 15 times in a loop; after the 15th run, exactly
14 `app-*.db` and 14 `media-*.tar.gz` artifacts remain in
`data/backups/` (newest kept, older pruned).
- [ ] Install the cron entry on the VM (example):
`15 3 * * * cd /srv/chicken_babies_site && ./scripts/backup.sh
>> /var/log/chicken_babies/backup.log 2>&1`.
### Gitea Actions workflow
- [ ] Push a commit to `master` on Gitea and confirm the
`Build and Push Docker Image` workflow runs to green.
- [ ] `git.sneakygeek.net/ptarrant/chicken_babies_site:latest` and
`:sha-<short>` tags exist in the registry afterwards.
- [ ] Pulling the `:latest` image and hitting `/healthz` reports the
expected `commit_sha` (matches the built commit).
### Ops smoke
- [ ] `pytest -q` passes with no new failures beyond the known
pre-existing two (`test_production_missing_key_refuses_startup`,
`test_layout_includes_logo_image`).
- [ ] `python -c "from app.main import app; print(len(app.routes))"`
prints the same route count as before Phase 6 (no new routes).