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).

View File

@@ -252,14 +252,42 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
**Branch:** `feat/phase-5-contact-form` off `dev`. Not committed, not merged, not pushed — changes staged for human review before `--no-ff` merge into `dev` per CLAUDE.md git strategy.
## Phase 6 — Hardening + Deploy
## Phase 6 — Hardening + Deploy
- Security headers middleware (strict nonce-based CSP, HSTS, etc.).
- CSRF middleware on admin routes (double-submit cookie).
- Structured access log + error log (structlog JSON in prod, pretty in dev).
- Backup script: `sqlite3 data/app.db ".backup data/backups/app-<ts>.db"` plus a tar of `data/media/`; cron nightly on the VM.
- Dockerfile hardening: non-root user, slim base, `HEALTHCHECK`.
- Gitea Action: on tag push, build image and publish to the internal registry.
**Completed:** 2026-04-22
**Summary:** Locked down the HTTP surface with a strict nonce-based CSP (plus HSTS in prod, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `frame-ancestors`, `form-action`), added a structured per-request access-log middleware with magic-link path redaction and `/healthz` suppression, hardened the Docker image (non-root uid/gid 10001 + `HEALTHCHECK` against `/healthz`), shipped a backup script with 14-day retention, and wired a Gitea Actions workflow that publishes `git.sneakygeek.net/ptarrant/chicken_babies_site:latest` + `:sha-<short>` on every push to `master` (plus `workflow_dispatch`). CSRF middleware was already live from Phase 4 — that roadmap bullet was a no-op here.
**Key files:**
- `app/middleware/__init__.py` — package marker.
- `app/middleware/security_headers.py``SecurityHeadersMiddleware(BaseHTTPMiddleware)`. Per-request nonce via `secrets.token_urlsafe(16)`, stashed on `request.state.csp_nonce`. CSP builds on each dispatch so the nonce threads into `script-src`. Conditional HSTS: constructor takes `production: bool`, only emits `Strict-Transport-Security: max-age=31536000; includeSubDomains` when `True` so localhost-over-http dev still works. Also sets `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`.
- `app/middleware/access_log.py``AccessLogMiddleware(BaseHTTPMiddleware)` emits one `http_request` INFO event per request with `method`, `path`, `status_code`, `duration_ms` (int), `client_ip` (from `request.client.host` — already resolved through `ProxyHeadersMiddleware`), `user_agent` (truncated to 256 chars). Skips `/healthz` to avoid compose-loop noise. Paths matching `^/admin/auth/consume/` have the token segment replaced with `<redacted>` before logging. Unhandled downstream exceptions are logged with `status_code=500`, then re-raised so FastAPI's exception machinery still runs.
- `app/main.py` — imports + wires both middlewares. Install order: `SecurityHeadersMiddleware` added first, `AccessLogMiddleware` added last. FastAPI/Starlette wraps in reverse registration order, so the access log runs outermost (timing covers the whole stack) and the security headers run innermost (stamped onto the response before the access log times it). Inline comment documents the ordering so future edits don't flip it.
- `app/templates/public/base.html` — line 110 inline `<script>` now carries `nonce="{{ request.state.csp_nonce }}"` so it passes strict CSP. External hCaptcha script on `public/contact.html` left alone (allowlisted by origin in `script-src`, not nonced). Admin templates were all-external-JS already; nothing to patch there.
- `Dockerfile` — runtime stage adds `groupadd -g 10001 app && useradd -m -u 10001 -g app app`, `chown -R app:app /app /opt/venv`, and `USER app` before `CMD`. Stable UID/GID keeps host bind-mount ownership predictable. `HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"` — stdlib only, no curl/wget runtime dep.
- `scripts/backup.sh``set -euo pipefail`, UTC timestamp (`%Y%m%dT%H%M%SZ`), `sqlite3 data/app.db ".backup data/backups/app-<ts>.db"` + `tar -czf data/backups/media-<ts>.tar.gz data/media`, retention `ls -t | tail -n +15 | xargs -r rm` so only the newest 14 of each artifact survive. `mkdir -p data/backups` is idempotent. Chmod +x committed.
- `.gitea/workflows/build-image.yml` — triggers on `push` to `master` + `workflow_dispatch`. Buildx + `docker/login-action@v3` using `${{ gitea.actor }}` / `${{ secrets.REGISTRY_TOKEN }}`. `metadata-action@v5` tags: `latest` + `sha-<short>`. `build-push-action@v5` passes `build-args: GIT_COMMIT_SHA=${{ gitea.sha }}` so `/healthz` reports the real commit in deployed images. Registry cache-from/to pinned to the `:buildcache` tag.
- `docs/MANUAL_TESTING.md` — appended Phase 6 checklist (security headers via curl, nonce uniqueness, HSTS dev/prod diff, hCaptcha still loads, admin editor still works, HEALTHCHECK `docker ps` healthy, backup dry run, retention prune).
- `tests/test_security_headers.py` (4 tests) — asserts every expected header is present on `/`, CSP contains `nonce-`, two back-to-back requests get different nonces, HSTS is absent when the middleware is constructed with `production=False` and present with `max-age=31536000; includeSubDomains` when `production=True`.
- `tests/test_access_log.py` (4 tests) — capsys-based capture with ANSI-escape stripping. Verifies `http_request` events fire for 200/404, `/healthz` is skipped, `/admin/auth/consume/<token>` is redacted before logging, and a downstream-handler exception is logged with `status_code=500` then re-raised.
**Endpoints created:** none. Phase 6 is cross-cutting — route count stayed at 28.
**Key details:**
- **CSP policy (effective):** `default-src 'self'; script-src 'self' 'nonce-<N>' 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'`. `style-src 'unsafe-inline'` is kept for now because hCaptcha injects inline style into its widget; tightening it is a future task.
- **Single inline script remaining:** `public/base.html`'s mobile-nav toggle. The admin editor and everything else are external files already, so no admin template needed a nonce. `contact.html`'s external hCaptcha script is allowed by origin; external scripts don't take nonces.
- **HSTS is production-gated.** Local dev over `http://127.0.0.1:8000` stays reachable. Prod flips on the header via the `production=(settings.app_env == "production")` flag in `create_app`.
- **Access log redaction is defence-in-depth.** The magic-link consume route already signs / hashes / single-uses the token, so even if a log line leaked it'd be unreplayable. Redacting anyway keeps the log grep-friendly and avoids any accidental downstream ingestion storing the raw value.
- **`/healthz` skipped from access logs** to keep compose's 30s HEALTHCHECK from drowning signal in noise. Still fully served — just not logged.
- **Middleware install order pitfall documented.** FastAPI/Starlette wrap in reverse registration order; the outermost middleware is the LAST one added. `app/main.py` now has an inline comment so a future refactor doesn't silently flip timing vs. security behaviour.
- **Docker image runs as non-root (uid 10001).** Host bind-mount (`./data:/app/data`) must be writable by this uid. Docs/MANUAL_TESTING notes `chown -R 10001:10001 data/` or mounting into a uid-remapped volume as the host-side prep step.
- **Gitea workflow publishes on every `master` push.** No tag trigger this phase; release tags can be layered on later without touching the workflow.
- **Build-arg discipline preserved.** Workflow passes `GIT_COMMIT_SHA=${{ gitea.sha }}` so the published image keeps reporting the correct SHA through `/healthz` — the Phase 0 contract holds.
- **Backup script is host-side only.** Cron installation is a sysadmin step documented in MANUAL_TESTING; the repo deliberately does not ship a systemd unit to keep it host-agnostic.
- **Pre-existing test failures are still pre-existing.** `test_production_missing_key_refuses_startup` (env pollution) and `test_layout_includes_logo_image` (Phase 4 `logo``logo-mark` asset rename) failed on `dev` before this phase; they still fail, and they are NOT Phase 6 regressions.
**Verification run:**
`python -c "from app.main import app; print(len(app.routes))"`**28** (unchanged) ✓ · `pytest -q`**149 passed, 2 failed** — both failures confirmed pre-existing on `dev` ✓ · dev-mode TestClient `/`: CSP present with unique nonce across two requests, HSTS absent, `X-Content-Type-Options=nosniff`, `Referrer-Policy=strict-origin-when-cross-origin`, `Permissions-Policy` present, `frame-ancestors 'none'` in CSP, nonce value matches the `nonce="..."` attribute emitted into the HTML ✓ · prod-mode (`APP_ENV=production` + full config env) `/`: `Strict-Transport-Security: max-age=31536000; includeSubDomains` present, JSON access-log line emitted ✓ · `/healthz` returns 200 without producing an `http_request` log entry ✓ · `bash -n scripts/backup.sh` clean ✓ · `python -c "import yaml; yaml.safe_load(open('.gitea/workflows/build-image.yml'))"` clean ✓ · `scripts/backup.sh` is `-rwxrwxr-x` (executable) ✓.
## Phase 7 (Future) — Shop