diff --git a/README.md b/README.md index 4fa31fb..0cbd25a 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,9 @@ either one is broken. We provide: - `script/frontend-fmt-check` — check prettier formatting (read-only) - `script/frontend-viewport-test` — responsive-layout verification of the built frontend in a containerised headless Chrome (see - [test/viewport/README.md](test/viewport/README.md)). Not part of + [test/viewport/README.md](test/viewport/README.md)). Before the browser + starts, it checks that the page, and a path that falls back to it, are served + with `Cache-Control: no-cache` and `Content-Security-Policy`. Not part of `script/check`: it takes minutes. - `script/docker` — build the image from `Dockerfile` without the build cache, tagged `netwatch` via `script/projectname` @@ -435,7 +437,10 @@ address nginx passes on and no other. The image: each entry with `netwatch-server check-cidr`, which parses it as the backend parses its own `TRUSTED_PROXIES` - Sends access logs to stdout -- Caches static assets with immutable headers +- Sends the page with `Cache-Control: no-cache`, so a browser that keeps a copy + checks with the server that it is current before using it again; only the + files under `/assets/`, whose names change whenever their contents do, are + cached as immutable - Sends the security headers `REPO_POLICIES.md` requires on every response, as `configs/security-headers.conf` sets them, in place of the backend's own - Stores reports in `DATA_DIR`, `/data/reports` by default, on the `/data` diff --git a/TODO.md b/TODO.md index ec56ac3..057b6f6 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,18 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of # Completed Steps +- 2026-10-18: the image's nginx sends the page with `Cache-Control: no-cache` + ([#141](https://git.eeqj.de/sneak/netwatch/issues/141)), so a browser that + keeps a copy checks with the server that it is current before using it again. + The page went out with no `Cache-Control`, so a browser could keep it after a + deploy and ask for files under `/assets/` that the new build no longer has, + and show an empty page. A path that `location /` answers with the page through + its `try_files` fallback gets the same header. `location /` now includes the + security headers again, as `/assets/` does, since its own `add_header` drops + the server's. `/assets/` keeps its immutable caching. + `make frontend-viewport-test` fails unless `/` and a path that falls back to + the page carry `Cache-Control: no-cache` and `Content-Security-Policy`, which + comes from the same file as the other security headers - 2026-10-18: a browser that cannot format the header's clocks still probes ([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which diff --git a/configs/nginx.conf b/configs/nginx.conf index bc0f4dd..ccb8aaf 100644 --- a/configs/nginx.conf +++ b/configs/nginx.conf @@ -30,8 +30,13 @@ server { access_log /dev/stdout combined; error_log /dev/stderr warn; + # With no-cache, a browser never uses a copy of the page that names + # assets a newer build no longer has without first checking with the + # server that it is current. location / { try_files $uri $uri/ /index.html; + add_header Cache-Control no-cache; + include /etc/nginx/security-headers.conf; } # Cache static assets aggressively diff --git a/script/frontend-viewport-test b/script/frontend-viewport-test index 42977a1..47d604b 100755 --- a/script/frontend-viewport-test +++ b/script/frontend-viewport-test @@ -7,6 +7,8 @@ # headless Chrome at it over CDP, and asserts on computed layout at every # viewport width derived from the app's own CSS. See # test/viewport/README.md for what this covers and what it cannot. +# Before the browser starts, it checks that the page is served with +# Cache-Control: no-cache and Content-Security-Policy. # # Deliberately not part of script/check: it needs Docker and takes far # longer than the 20s budget make test has to stay inside. @@ -75,6 +77,32 @@ main() { -v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \ "$SERVER_IMAGE" > /dev/null + # The page, and /no-such-page, which location / answers with the + # page through its try_files fallback, must go out with + # Cache-Control: no-cache. That add_header in its location drops the + # server's own unless the location includes the security headers + # again; only Content-Security-Policy is checked, as it comes from + # the same file as the rest. curl tries up to ten more times, a + # second apart, while nginx starts. It runs without -S, which would + # print an error for every failed attempt, even when a later one + # succeeds. + for path in / /no-such-page; do + if ! headers="$(docker exec "$SERVER" curl -s -o /dev/null -D - \ + --retry 10 --retry-connrefused --retry-delay 1 \ + "http://127.0.0.1:8080$path")"; then + echo "frontend-viewport-test: nginx did not answer $path" >&2 + exit 1 + fi + headers="$(printf '%s\n' "$headers" | tr -d '\r')" + if ! printf '%s\n' "$headers" | grep -qix 'Cache-Control: no-cache' || + ! printf '%s\n' "$headers" | grep -qi '^Content-Security-Policy:'; then + echo "frontend-viewport-test: $path needs" \ + "Cache-Control: no-cache and Content-Security-Policy; got:" >&2 + printf '%s\n' "$headers" >&2 + exit 1 + fi + done + # The image's own entrypoint already exposes CDP on 9222 and passes # --no-sandbox, so only extra flags belong here; re-specifying the # debugging port collides with it and leaves the endpoint bound to