Patch notes — image & runtime hardening (2026-06-19)
A hardening batch on top of the 2026-06-18 security fixes: the whole site now
carries defensive HTTP headers, server-side probes are SSRF-guarded, and the
container image was rebuilt onto a distroless base that ships 0 Critical/High
CVEs and runs as a non-root user. All deployed to main. Docker smoke 157/0
throughout.
1 — Site-wide security headers ✅
X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN and
Referrer-Policy: strict-origin-when-cross-origin are now emitted on every
response. They previously only covered the dynamic /admin + /api responses;
the static Hugo marketing pages were served straight by the stdlib file handler
and got none, leaving them clickjackable. Moved the headers into a single
end_headers() override so static, dynamic, redirect and error responses are all
covered uniformly. SAMEORIGIN (not DENY) so the admin’s own newsletter/content
preview iframes keep working. Commit 2cf7fd4.
2 — SSRF guard on server-side probes ✅
Admin-supplied monitor / api-source / deployment URLs are fetched server-side by
the in-process deploy checker and the collector. Added probe_url_allowed /
probe_host_allowed: each target host is resolved and rejected if it lands on
link-local (incl. the 169.254.169.254 cloud-metadata endpoint), loopback,
unspecified, multicast or reserved ranges. Private LAN is intentionally allowed
— probing internal hosts is what a monitoring tool does. Guards both the web
checker (_http_check / _ssl_days_left) and the collector’s monitor + api-source
fetches. Commit f3f8624.
3 — Distroless runtime image (0 Critical/High CVEs, non-root) ✅
Rebuilt the runtime from python:3.13-slim (Debian trixie) onto a 3-stage build
ending on gcr.io/distroless/cc-debian12 with our own Python 3.13. Outcome,
measured with Trivy:
| Before (slim) | After (distroless) | |
|---|---|---|
| Tracked CVEs | ~56 | ~14 |
| Critical / High | 2 / 9 | 0 / 0 |
| Image size | 368 MB | 69 MB |
| Runs as | root | non-root (uid 65532) |
| Shell / apt / perl / tar | present | removed |
Context: a CVE scan found all 56 base-image CVEs were inherited from Debian and
none had an upstream fix available, so a base swap (not a rebuild) was the only
lever. The two Criticals were perl-Archive-Tar (CVE-2026-42496/42497) — perl is
gone entirely from the distroless image. ping-type monitors are unaffected
(neither image ever shipped ping). Commit 1d7d3a1.
4 — Supporting fixes ✅
- collector/fallback entrypoint — the distroless image sets an
ENTRYPOINT(no shell), so the composecommand:overrides would have silently run the web server in the collector and 404-fallback containers. Switched both toentrypoint:; verified the collector runs its loop and the fallback serves the branded 404. Commit4aee470. - CI image tag — now
YYYY.MM.DD.<run_number>(unique, sortable) so tag-based rollback actually works; the old logic always produced.1. Commita26404d. compose.dev.yml— runs Postgres now; it was stale SQLite-era and couldn’t boot the current code. Commit10013f7.- Dropped vestigial
ETHICA_DB_PATH— a SQLite path in a Postgres-only app, read nowhere. Commit4d040a9.
Verification
Docker smoke 157/0 on every step; full py_compile clean; newsletter (7) and
docs_reader (30) unit tests pass; the distroless image was booted end-to-end
(web + collector + fallback) before deploy.