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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user