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
@@ -1,48 +0,0 @@
# Phase 03 — Security headers on every location (audit M-4)
**Story:** n/a
**Context:** `.agents/PLAN.md` §3 (serving + edge), `.agents/remediation_plan.md`
findings M-4 (headers) and L-2 (`server_tokens`, included as a one-liner in
the same file)
## Objective
Fix the nginx `add_header` inheritance trap: the `location = /index.html`
and the hashed-asset location each define their own cache `add_header`
directives, so they **lose** the server-level security headers (CSP,
X-Frame-Options, X-Content-Type-Options, Referrer-Policy). Extract the four
security headers into a snippet and `include` it in **every** location, so
every response class carries the full header set. Also set
`server_tokens off;` (L-2) in the same pass.
## Dependencies
— (none)
## Tasks
1. `01_extract_header_snippet.md` — Create `nginx-security-headers.inc`, wire it into the Dockerfile, and `include` it in the server block and both header-defining locations (plus `server_tokens off;`).
2. `02_add_header_verification.md` — Add `scripts/check-headers.sh` that builds and runs the real image and asserts all four headers on `/`, `/index.html`, and a hashed asset.
## Testing & Quality
- Unit/integration: the container-based header check (task 02) is the
integration test. It must assert all four headers on each of the three
response classes.
- Coverage: **>90%** on new/modified config (all three location paths
exercised by the check script).
- The Playwright E2E suite is unaffected (its webServer is plain python,
not nginx) and must stay green.
## Completion Criteria
- [ ] `nginx-security-headers.inc` exists and holds the four `add_header …
always;` lines
- [ ] `nginx.conf` has **no** inline security `add_header` lines (only the
`include`), and both header-defining locations `include` the snippet
- [ ] `server_tokens off;` present in the `server` block
- [ ] `scripts/check-headers.sh` runs the real image and prints
`HEADER CHECK PASSED` (all four headers on `/`, `/index.html`, and a
hashed asset)
- [ ] `./build.sh && npm test` fully green
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
**Out of scope (separate, unphased):** L-1 HSTS — that is an **edge/Caddy**
responsibility (the container serves plain HTTP on 8080). Verify at the edge
manually: `curl -sI https://reeseapps.com/ | grep -i strict-transport`. Do
**not** add HSTS inside the 8080 container.
@@ -1,96 +0,0 @@
# Task 01 — Extract header snippet and include it in every location
**Phase:** `03_nginx_security_headers` · **Story:** n/a
## Objective
Move the four security headers into a shared snippet and `include` it in
**every** location, so no location can silently drop them by defining its own
`add_header` (the nginx inheritance trap, audit M-4). Add `server_tokens off;`
(audit L-2) in the same pass.
## Work
1. Create `nginx-security-headers.inc` (repo root, next to `nginx.conf`) with
exactly:
```
# Shared security headers — included by EVERY nginx location.
# A location that defines its own add_header inherits none from the
# server block, so each location includes this snippet directly.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
```
2. `Dockerfile` — create the snippets dir and copy the file in. Change the
existing `RUN rm … && apk add …` line to also `mkdir -p /etc/nginx/snippets`,
and add a COPY right before the `COPY nginx.conf …` line:
```dockerfile
RUN rm /etc/nginx/conf.d/default.conf && \
apk add --no-cache coreutils && \
mkdir -p /etc/nginx/snippets
COPY nginx-security-headers.inc /etc/nginx/snippets/security-headers.inc
COPY nginx.conf /etc/nginx/conf.d/default.conf
```
3. `nginx.conf` — rewrite to the exact content below. Key changes: the four
inline `add_header` lines are gone from the `server` block; `server_tokens
off;` is added; each of the three `location` blocks `include`s the snippet
(so no location relies on header inheritance):
```nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Hide the nginx version in the Server header (audit L-2)
server_tokens off;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 256;
gzip_vary on;
# Never cache index.html so the latest HTML is always served
location = /index.html {
include /etc/nginx/snippets/security-headers.inc;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# Cache hashed assets forever (filename contains content hash)
location ~* \.(css|js|jpeg|jpg|png|gif|ico|svg|woff2?)$ {
include /etc/nginx/snippets/security-headers.inc;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
include /etc/nginx/snippets/security-headers.inc;
try_files $uri $uri/ /index.html;
}
# Custom error pages
error_page 404 /index.html;
}
```
## Testing & Quality
- Unit/integration: config correctness is proven by the container check in
task 02 (headers present on all three response classes) and by `nginx -t`
(valid syntax, run implicitly by the container starting).
- Coverage: **>90%** on the modified config (all three locations exercised).
## Completion Criteria
- [ ] `nginx-security-headers.inc` exists with the four `add_header … always;`
lines
- [ ] `nginx.conf` contains **no** inline security `add_header` lines and has
`include /etc/nginx/snippets/security-headers.inc;` in all three
locations, plus `server_tokens off;`
- [ ] `Dockerfile` copies the snippet into the image
- [ ] `./build.sh && npm test` still fully green (Playwright is unaffected)
@@ -1,87 +0,0 @@
# Task 02 — Add container-level header verification
**Phase:** `03_nginx_security_headers` · **Story:** n/a
## Objective
Add a repeatable, container-based verification that the **real** image serves
all four security headers on every response class (`/`, `/index.html`, and a
hashed asset). This is the integration test for the M-4 fix and guards against
future nginx config regressions.
## Work
1. Create `scripts/check-headers.sh` (repo root; `mkdir -p scripts` first)
with exactly:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Verify every response class (/, /index.html, a hashed asset) carries the
# full security-header set, using the REAL nginx image on a scratch port.
# Requires a container runtime (podman or docker).
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if command -v podman >/dev/null 2>&1; then RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then RUNTIME=docker
else echo "ERROR: neither podman nor docker found" >&2; exit 2; fi
IMAGE="homepage-headercheck-$$"
PORT=18099
CID=""
trap 'if [ -n "$CID" ]; then $RUNTIME rm -f "$CID" >/dev/null 2>&1 || true; fi' EXIT
echo ">> building image ($RUNTIME)..."
$RUNTIME build -q -t "$IMAGE" .
echo ">> starting container on 127.0.0.1:$PORT..."
CID="$($RUNTIME run -d --rm -p 127.0.0.1:$PORT:8080 "$IMAGE")"
for i in $(seq 1 30); do
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
sleep 0.5
if [ "$i" -eq 30 ]; then echo "ERROR: container not ready" >&2; exit 1; fi
done
ASSET="$(curl -s "http://127.0.0.1:$PORT/" \
| grep -oE '(style|script|terminal)[a-z-]*\.[0-9a-f]{32}\.(css|js)' | head -n1 || true)"
if [ -z "$ASSET" ]; then echo "ERROR: no hashed asset found in index.html" >&2; exit 1; fi
fail=0
check_headers() {
local path="$1" hdrs h
hdrs="$(curl -sI "http://127.0.0.1:$PORT$path")"
for h in "X-Frame-Options" "X-Content-Type-Options" "Referrer-Policy" "Content-Security-Policy"; do
if ! printf '%s' "$hdrs" | grep -qi "^${h}:"; then
echo "FAIL: ${h} missing on ${path}" >&2; fail=1
fi
done
[ "$fail" -eq 0 ] && echo "OK: all security headers present on ${path}"
}
echo ">> checking headers..."
check_headers "/"
check_headers "/index.html"
check_headers "/${ASSET}"
if [ "$fail" -ne 0 ]; then echo "HEADER CHECK FAILED" >&2; exit 1; fi
echo "HEADER CHECK PASSED"
```
Then `chmod +x scripts/check-headers.sh`.
2. Run it: `./scripts/check-headers.sh` — it must print
`HEADER CHECK PASSED` with an `OK:` line for each of the three paths.
## Testing & Quality
- Unit/integration: this script **is** the integration test — it builds the
real image, serves it, and asserts all four headers on each response class.
- Coverage: **>90%** on the modified nginx config (all three location paths
asserted).
## Completion Criteria
- [ ] `scripts/check-headers.sh` exists and is executable
- [ ] `./scripts/check-headers.sh` exits 0 and prints `HEADER CHECK PASSED`
(with `OK:` lines for `/`, `/index.html`, and the hashed asset)
- [ ] (negative check) temporarily removing one `include` from a location in
`nginx.conf` makes the script FAIL for that path — confirms the check
has teeth, then restore it
- [ ] `./build.sh && npm test` fully green