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

64
scripts/backup.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# scripts/backup.sh — local SQLite + media snapshot
#
# Produces two artifacts under data/backups/:
# app-<ts>.db : SQLite .backup of the live database (safe while
# the app is running; sqlite3 .backup coordinates
# with WAL).
# media-<ts>.tar.gz : tar archive of the media upload directory.
#
# Retention: keep the 14 newest of each artifact; prune the rest.
#
# The script is intended to be invoked by cron on the Debian VM. Example
# crontab line (daily at 03:15 UTC):
# 15 3 * * * cd /srv/chicken_babies_site && ./scripts/backup.sh \
# >> /var/log/chicken_babies/backup.log 2>&1
#
# Exit codes:
# 0 on success. Any failing step aborts via `set -e` with a non-zero
# status so cron emails / log scrapers can flag it.
# ---------------------------------------------------------------------------
set -euo pipefail
# UTC timestamp: sortable, compact, unambiguous. Matches the ISO-8601
# "basic" form with a literal Z suffix.
TS="$(date -u +%Y%m%dT%H%M%SZ)"
BACKUP_DIR="data/backups"
DB_SRC="data/app.db"
MEDIA_SRC="data/media"
# Ensure the output directory exists. `mkdir -p` is idempotent and never
# fails if the directory already exists, which is exactly what we want.
mkdir -p "${BACKUP_DIR}"
# --- SQLite snapshot ------------------------------------------------------
# Use sqlite3's .backup command rather than `cp` so we get a consistent
# copy even if the database is actively being written to. sqlite3 serialises
# with the WAL and produces a file that's safe to open elsewhere.
DB_DEST="${BACKUP_DIR}/app-${TS}.db"
sqlite3 "${DB_SRC}" ".backup ${DB_DEST}"
# --- Media archive --------------------------------------------------------
# tar the whole media tree. gzip compression keeps the archive small for
# typical image payloads; restore is just `tar -xzf`.
MEDIA_DEST="${BACKUP_DIR}/media-${TS}.tar.gz"
tar -czf "${MEDIA_DEST}" "${MEDIA_SRC}"
# --- Retention prune -------------------------------------------------------
# Keep the 14 newest of each type; delete the rest. ls -t sorts newest-
# first; tail -n +15 drops the first 14 lines (the survivors). We guard
# each rm with `|| true` so a cold start with fewer than 14 artifacts
# doesn't cause the script to exit non-zero via `set -e`.
keep_newest_14() {
local pattern="$1"
# shellcheck disable=SC2012 # `ls -t` is the simplest way to sort by mtime
ls -1t ${pattern} 2>/dev/null | tail -n +15 | xargs -r rm -f
}
keep_newest_14 "${BACKUP_DIR}/app-*.db"
keep_newest_14 "${BACKUP_DIR}/media-*.tar.gz"
echo "backup complete: ${DB_DEST}, ${MEDIA_DEST}"