phase: 03_nginx_security_headers
Build and Push Container / build-and-push (push) Successful in 23s

**Phase 03 final verification — all green**

- Verified (all tasks already in `complete/`): snippet/Dockerfile/nginx.conf wiring, `scripts/check-headers.sh`, guardrail teeth, restored config byte-identical.
- Tests (full suite): `./build.sh` → OK; `npm test` → **46 passed, 0 failed**; `bash .agents/validate.sh` → **exit 0** (46/46 + `HEADER CHECK PASSED` + hygiene gates); no leftover podman images.
- Completion criteria:
  - `nginx-security-headers.inc` with 4 `add_header … always;` lines — ✅
  - `nginx.conf`: 0 inline security `add_header` (cache headers only), 3/3 locations `include` snippet — ✅
  - `server_tokens off;` in server block — ✅ (live proof: `Server: nginx`, no version)
  - `./scripts/check-headers.sh` → `HEADER CHECK PASSED`, OK on `/`, `/index.html`, hashed asset — ✅ (also confirmed all 4 headers + correct Cache-Control on 404 fallback)
  - `./build.sh && npm test` fully green — ✅
  - No behavior change (terminal/vim/achievements/menu suites) — ✅
  - Negative check re-run: dropping one `include` → `FAIL … on /style.<hash>.css`, exit 1 — ✅
- Docs: README.md & AGENTS.md reviewed in full against actual state (commands, ports 8123/18099, header mechanism, CI tags vs `.gitea/workflows/build-push.yml`, Node ≥20 engine floor, innerHTML/neofetch exception at `terminal.js:487`, simulated curl/wget guards) — **verified consistent, no changes needed**.
- Notable: check-headers.sh aborts at the first failing path via `set -e` before printing `HEADER CHECK FAILED` — README's wording matches this exactly (verified empirically). PLAN.md's "44 tests" count is stale (now 46) but PLAN.md is locked — left for the user.
- No live-infra changes (no HSTS in container; edge/Caddy untouched).
- Next pending phase: `04_huntington_role`.
This commit is contained in:
2026-10-23 00:07:13 -04:00
parent bcfaa1cac6
commit 47b7b4fc23
20 changed files with 1459 additions and 16 deletions
+34 -10
View File
@@ -34,21 +34,42 @@ Visit `http://localhost:8080` in your browser.
## Serving & configuration
`nginx.conf` is copied into the image as the site config. It listens on
**8080 over plain HTTP**, serves the hashed build, declares the hardening
headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`,
`Content-Security-Policy`), serves `index.html` with `no-cache`, and caches the
hashed assets as `immutable` for a year.
**8080 over plain HTTP**, serves the hashed build, serves `index.html` with
`no-cache`, and caches the hashed assets as `immutable` for a year.
Known gap (audit finding M-4, Phase 03): nginx does not merge `add_header`
across configuration levels, so the `location` blocks that set their own headers
drop the server-level security headers — measured against the built image, `/`,
`/index.html` and a hashed asset currently get only the cache headers. Every
`add_header` block must repeat the hardening headers until that phase lands.
The four hardening headers (`X-Frame-Options`, `X-Content-Type-Options`,
`Referrer-Policy`, `Content-Security-Policy`) live in
`nginx-security-headers.inc`, which the Dockerfile copies to
`/etc/nginx/snippets/security-headers.inc`. nginx does **not** merge
`add_header` across configuration levels, so every `location` block `include`s
that snippet instead of inheriting from the `server` block — otherwise the
locations that set their own cache headers would silently drop the security
headers (audit finding M-4, Phase 03). `server_tokens off;` hides the nginx
version from the `Server` header (audit finding L-2). When adding a new
`location`, `include` the snippet there too and keep the security headers out of
`nginx.conf` itself.
TLS and HSTS are the job of the reverse proxy in front of the container
(`reeseapps.com` terminates at the Caddy edge). Do **not** add
`Strict-Transport-Security` or a `listen 443` block to `nginx.conf`.
### Verify the headers
```bash
./scripts/check-headers.sh
```
This is the integration test for the header fix: it builds the **real** image
with podman (or docker), runs it on the scratch address `127.0.0.1:18099`, and
asserts all four security headers on each response class — `/`, `/index.html`
and the first hashed asset found in `index.html`. On success it prints one
`OK:` line per path and `HEADER CHECK PASSED` (exit 0); a missing header prints
`FAIL: <header> missing on <path>` and exits non-zero, aborting at the first
failing path. No local `./build.sh` is needed — the image builds itself. The
container is removed by the script; `.agents/validate.sh` removes the leftover
`homepage-headercheck-*` image tag. Run it after **any** change to
`nginx.conf`, `nginx-security-headers.inc` or the `Dockerfile`.
## Deploy with Quadlet
The `homepage.container` Quadlet unit is **not** tracked in this repository — it
@@ -115,4 +136,7 @@ revision cached for the pinned Playwright version — run
bumping the pin.
The validation gate the phased pipeline runs per task is `.agents/validate.sh`
(full E2E suite plus repo-hygiene checks): `bash .agents/validate.sh`.
(full E2E suite, the container security-header check from
`scripts/check-headers.sh`, plus repo-hygiene checks): `bash
.agents/validate.sh`. The header check is skipped with a loud `WARN:` line on a
host with no container runtime; everything else still runs.