From d81da05748ffb122682964da4e80e083de3ca326 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 10:22:12 +0200 Subject: [PATCH] nginx: security headers on every response (closes #18) nginx sent none of the security headers REPO_POLICIES.md requires. security-headers.conf now sets all six with always, included at server level and again in /assets/, whose own add_header would otherwise drop them. nginx hides the copies netwatch-server sets, so /api/ and the health check carry each header once. The content security policy allows no inline script or style; the host row's status dot took its grey from a style attribute, now a class. connect-src is * because several probed hosts redirect to other hosts and the browser checks every redirect against it. Referrer-Policy is no-referrer, as the backend already sends. Model: opus-5-5 --- Dockerfile | 1 + README.md | 2 ++ TODO.md | 7 +++++++ backend/README.md | 4 +++- nginx.conf | 16 ++++++++++++++++ script/frontend-viewport-test | 4 +++- security-headers.conf | 24 ++++++++++++++++++++++++ src/main.js | 2 +- 8 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 security-headers.conf diff --git a/Dockerfile b/Dockerfile index 87435d2..417fc97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,6 +72,7 @@ RUN addgroup -g 1000 -S netwatch && \ # conf.d; bin/entrypoint.sh says how. RUN rm /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/templates/netwatch.conf.template +COPY security-headers.conf /etc/nginx/security-headers.conf COPY --from=frontend /app/dist /usr/share/nginx/html COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server COPY bin/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/README.md b/README.md index 2f46400..415457e 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ only inside the container, on `127.0.0.1:8081`. The image: reverse proxies named in `TRUSTED_PROXIES`, and by default from none - Sends access logs to stdout - Caches static assets with immutable headers +- Sends the security headers `REPO_POLICIES.md` requires on every response, as + `security-headers.conf` sets them, in place of the backend's own - Stores reports in `DATA_DIR`, `/data/reports` by default, on the `/data` volume. The backend runs as user `netwatch` (uid 1000), so a directory bind-mounted at `/data` must be writable by uid 1000 diff --git a/TODO.md b/TODO.md index 623f8cf..e37242e 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,13 @@ latest run passes. # Completed Steps +- 2026-09-29: nginx sends the security headers `REPO_POLICIES.md` requires on + every response (issue #18), including errors, `/assets/` and what it passes on + from the backend, whose own copies it drops so each header goes out once. They + live in `security-headers.conf`, which `nginx.conf` includes. The content + security policy allows no inline script or style, so the status dot's grey in + `src/main.js` is now a class; `connect-src` is `*` because probed hosts + redirect to others, and the browser checks each redirect against it - 2026-09-29: the request log is bounded (issue #60): the method, URL, protocol, `User-Agent`, `Referer`, request ID (which chi takes from the client's `X-Request-Id` header) and client address it writes are each cut to 128 bytes, diff --git a/backend/README.md b/backend/README.md index 5f80ef2..ad29c08 100644 --- a/backend/README.md +++ b/backend/README.md @@ -104,7 +104,9 @@ this server. The image's entrypoint, `bin/entrypoint.sh`, starts the server as user `netwatch` (uid 1000) with `BIND_ADDRESS=127.0.0.1` and `PORT=8081`, so only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on -the `/data` volume, which `netwatch` owns. +the `/data` volume, which `netwatch` owns. nginx replaces the security headers +this server sets with those in the root `security-headers.conf`, so those are +what clients of the image see. The container's own `TRUSTED_PROXIES` goes to nginx instead: IP addresses or CIDRs, separated by commas, of the reverse proxies in front of the container. diff --git a/nginx.conf b/nginx.conf index 0322c27..3ea963a 100644 --- a/nginx.conf +++ b/nginx.conf @@ -8,6 +8,11 @@ server { # Keep the nginx version out of the Server header and error pages. server_tokens off; + # The security headers, on every response. An add_header in a + # location drops every add_header from here, so a location with one + # of its own includes this file again. + include /etc/nginx/security-headers.conf; + root /usr/share/nginx/html; index index.html; @@ -32,6 +37,7 @@ server { location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; + include /etc/nginx/security-headers.conf; } # netwatch-server, the Go backend, runs in the same container and @@ -45,6 +51,16 @@ server { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; + # netwatch-server sets the same security headers on its own + # responses. Its copies are dropped so that each header goes out + # once, as security-headers.conf sets it. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + proxy_hide_header X-Content-Type-Options; + proxy_hide_header Referrer-Policy; + proxy_hide_header Permissions-Policy; + location /api/ { proxy_pass http://127.0.0.1:8081; } diff --git a/script/frontend-viewport-test b/script/frontend-viewport-test index e4b53d1..d26e0fe 100755 --- a/script/frontend-viewport-test +++ b/script/frontend-viewport-test @@ -64,13 +64,15 @@ main() { # nginx.conf is a template: the image renders it over its own # default.conf, with the same port and limit bin/entrypoint.sh uses. # The empty file it includes trusts no proxy, as bin/entrypoint.sh - # writes it when TRUSTED_PROXIES is unset. + # writes it when TRUSTED_PROXIES is unset. nginx.conf also includes + # the security headers, so the page runs under the shipped policy. docker run -d --rm --name "$SERVER" \ --network "$NETWORK" --network-alias netwatch \ -e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \ -v "$ROOT/dist:/usr/share/nginx/html:ro" \ -v "$ROOT/nginx.conf:/etc/nginx/templates/default.conf.template:ro" \ -v /dev/null:/etc/nginx/trusted-proxies.conf:ro \ + -v "$ROOT/security-headers.conf:/etc/nginx/security-headers.conf:ro" \ "$SERVER_IMAGE" > /dev/null # The image's own entrypoint already exposes CDP on 9222 and passes diff --git a/security-headers.conf b/security-headers.conf new file mode 100644 index 0000000..6602d6e --- /dev/null +++ b/security-headers.conf @@ -0,0 +1,24 @@ +# The security headers REPO_POLICIES.md requires on every response. +# nginx.conf includes this file, which Dockerfile copies to +# /etc/nginx/security-headers.conf. always sends each header on error +# responses too. + +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + +# Scripts and styles load only from the page's own origin. Inline ones +# are blocked, style attributes in markup included, so style elements +# through classes or element.style. data: images are for the favicon +# in index.html. connect-src is * because the browser checks each probe in +# src/main.js against it, and also every redirect the probe follows, +# and several of those hosts redirect to others; a list of hosts here +# would block those probes. It also covers the reports the page sends +# to its own origin. +add_header Content-Security-Policy "default-src 'self'; connect-src *; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; + +add_header X-Frame-Options DENY always; +add_header X-Content-Type-Options nosniff always; + +# The probed hosts are not told where the page is served from. +add_header Referrer-Policy no-referrer always; + +add_header Permissions-Policy "accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), usb=()" always; diff --git a/src/main.js b/src/main.js index 21b4a62..526a544 100644 --- a/src/main.js +++ b/src/main.js @@ -716,7 +716,7 @@ function hostRowHTML(host, index, showPin = true) { ${pinBtn}
-
+
${host.name}