diff --git a/CHANGELOG.md b/CHANGELOG.md index 094ae1b..924f2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] - 2026-09-10 + +Container and response-header hardening (WP-13: M31, M34). + +### ⚠ Breaking + +- **The production container now listens on 8080, not 80.** nginx runs as an + unprivileged user, and a non-root process cannot bind a privileged port without + `CAP_NET_BIND_SERVICE`, which compose drops. Anything routing to this container + must be repointed: in the homelab Traefik config that means + `traefik.http.services.movieloop.loadbalancer.server.port` changes from `80` to + `8080`. The published mapping in `docker-compose.prod.yml` is updated here, but + the Traefik label lives outside this repo and must be changed by hand. + +### Fixed + +- Security headers are no longer dropped from most responses. nginx's + `add_header` is not additive across levels: a block declaring any `add_header` + of its own discards every header inherited from its parent. `location /assets/` + and `location = /config.js` each declared one, so `X-Frame-Options`, + `X-Content-Type-Options` and `Referrer-Policy` were silently absent from every + JavaScript and CSS response - which is most of what a browser loads. The + headers now come from a snippet included in each location. Verified against the + built image: 6 of 6 headers present on `/`, on a hashed `/assets/*.js` bundle, + and on `/config.js`. +- `config.js` no longer interpolates `API_URL` unescaped. This used `envsubst`, + which does no escaping, so a value containing a double quote closed the + JavaScript string literal and everything after it became executable script on + every page load. The value is now escaped when rendered. Operator-supplied, so + not a likely attack - but the page hands its auth token to whatever origin that + value resolves to, and a malformed value is now inert rather than executable. + +### Added + +- A Content-Security-Policy, plus `Strict-Transport-Security` and + `Permissions-Policy`. With the JWT in `localStorage`, CSP is the main + compensating control against injected script and there was none. `connect-src` + is derived at container start from `API_URL`, because the API is not + same-origin in production - the app is served from one subdomain and the API + from another, so naming it explicitly is required or every request and + websocket would be blocked. The inline theme script in `index.html` runs before + first paint to avoid a flash of the wrong theme and so cannot move to a file; + it is allowed by SHA-256 hash rather than by `unsafe-inline`, which would have + bought almost nothing. **If that script is edited its hash must be regenerated + in `docker/40-render-config.sh` or the page will not apply its theme.** + +### Changed + +- The production image is based on `nginxinc/nginx-unprivileged:alpine` and runs + as uid 101 rather than root. It serves static files and never needed root. + Verified in the built image: `uid=101(nginx)`. +- `envsubst` is no longer installed, since the entrypoint renders `config.js` + itself in order to escape the value. + ## [1.3.0] - 2026-09-10 Session handling and effect correctness. diff --git a/Dockerfile b/Dockerfile index 8dd3356..1888299 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,15 +21,30 @@ COPY . . RUN npm run build # --- Production stage --- -FROM nginx:alpine AS production +# nginx-unprivileged rather than nginx: the stock image runs its master process +# as root, which this container has no need for - it serves static files. It +# listens on 8080 because a non-root process cannot bind a privileged port +# without CAP_NET_BIND_SERVICE, and that capability is dropped in compose. +FROM nginxinc/nginx-unprivileged:alpine AS production + +# Package upgrades and directory ownership need root; the image drops back to +# its unprivileged user before nginx starts. +USER root # Pull current security fixes for OS packages inherited from the base image # (e.g. nghttp2-libs CVE-2026-27135). envsubst is no longer installed: # 40-render-config.sh writes config.js itself so it can escape the value, which # envsubst cannot do. RUN apk upgrade --no-cache + COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf COPY docker/40-render-config.sh /docker-entrypoint.d/40-render-config.sh -RUN chmod +x /docker-entrypoint.d/40-render-config.sh -EXPOSE 80 -# nginx:alpine's upstream entrypoint runs /docker-entrypoint.d/*.sh then launches nginx. + +# The entrypoint script writes config.js and the security-header snippet at +# start-up, so both destinations must be writable by the unprivileged user. +# 101 is the nginx uid/gid in this image. +RUN chmod +x /docker-entrypoint.d/40-render-config.sh && mkdir -p /etc/nginx/snippets && chown -R 101:101 /usr/share/nginx/html /etc/nginx/snippets + +USER 101 +EXPOSE 8080 +# The upstream entrypoint runs /docker-entrypoint.d/*.sh then launches nginx. diff --git a/docker/40-render-config.sh b/docker/40-render-config.sh index 1257e3b..c9b047f 100644 --- a/docker/40-render-config.sh +++ b/docker/40-render-config.sh @@ -1,10 +1,14 @@ #!/bin/sh -# Render /config.js from the env var API_URL at container start. -# Runs as part of the official nginx:alpine /docker-entrypoint.d sequence, -# so nginx itself is launched by the upstream entrypoint after this script. +# Renders the two things that depend on runtime configuration, before nginx +# starts: /config.js (the API base the app reads) and the security-header +# snippet (whose CSP has to name that same API origin). +# +# Runs as part of the official nginx:alpine /docker-entrypoint.d sequence, so +# nginx itself is launched by the upstream entrypoint after this script. set -e : "${API_URL:=/api/v1}" +# --------------------------------------------------------------- config.js # The value is interpolated into a JavaScript string literal, so it is escaped # rather than substituted raw. This previously used envsubst, which does no # escaping at all: an API_URL containing a double quote closed the literal and @@ -22,4 +26,48 @@ escaped=$(printf '%s' "$API_URL" | sed 's/[\\"]/\\&/g' | tr -d '\n\r') printf 'window.__APP_CONFIG__ = { apiUrl: "%s" };\n' "$escaped" \ > /usr/share/nginx/html/config.js +# ------------------------------------------------------- security headers +# The API is not necessarily same-origin: in production the app is served from +# one subdomain and the API from another, so connect-src has to name it +# explicitly or every request and every websocket would be blocked. +# +# A relative API_URL means same-origin, and 'self' already covers it. +api_origin="" +case "$API_URL" in + http://*|https://*) + # scheme://host[:port] - everything up to the third slash. + api_origin=$(printf '%s' "$API_URL" | cut -d/ -f1-3) + ;; +esac + +# Websockets need their own entry: ws(s): is a distinct scheme to CSP even when +# the host is identical. +ws_origin="" +if [ -n "$api_origin" ]; then + ws_origin=$(printf '%s' "$api_origin" | sed -e 's/^http:/ws:/' -e 's/^https:/wss:/') +fi + +connect_src="'self' ${api_origin} ${ws_origin}" + +# The inline theme script in index.html runs before first paint to avoid a +# flash of the wrong theme, so it cannot be moved to a file without either a +# blocking request or the flash coming back. Allowing it by hash keeps +# 'unsafe-inline' out of the policy - with 'unsafe-inline' a CSP buys almost +# nothing against injected script, which is the main thing it is here to stop. +# +# If that script is edited, this hash must be regenerated or the page will not +# apply its theme. It is asserted by the header test in the repo. +theme_script_hash="sha256-8evBcs4UKW13abYRRwTcDjBEK5h9IWhKnpEbZ+Mv4nQ=" + +mkdir -p /etc/nginx/snippets +cat > /etc/nginx/snippets/security-headers.conf < /config.js" +echo "[config] CSP connect-src: ${connect_src}" diff --git a/nginx.conf b/nginx.conf index af2f1fd..b69c684 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,14 +1,22 @@ server { - listen 80; - listen [::]:80; + # 8080, not 80: the server runs unprivileged and cannot bind a + # privileged port without a capability that compose drops. + listen 8080; + listen [::]:8080; server_name localhost; root /usr/share/nginx/html; index index.html; - # Security headers - 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; + # Security headers are pulled in from a snippet rendered at container start + # (docker/40-render-config.sh), because the CSP has to name the API origin + # and that is only known from API_URL at runtime. + # + # The include is repeated in every location that declares an add_header of + # its own. nginx's add_header is NOT additive across levels: a block that + # sets any add_header discards every header inherited from its parent. That + # is why these headers were silently missing from every JavaScript and CSS + # response, which is most of what a browser loads. + include /etc/nginx/snippets/security-headers.conf; # Gzip compression gzip on; @@ -17,18 +25,21 @@ server { # Cache static assets location /assets/ { + include /etc/nginx/snippets/security-headers.conf; expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, immutable" always; } # Runtime config — rendered fresh on each container start. Never cache. location = /config.js { + include /etc/nginx/snippets/security-headers.conf; add_header Cache-Control "no-store" always; try_files /config.js =404; } # SPA fallback — serve index.html for all non-file routes location / { + include /etc/nginx/snippets/security-headers.conf; try_files $uri $uri/ /index.html; } } diff --git a/package-lock.json b/package-lock.json index 5126a00..524dd69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend-temp", - "version": "1.2.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend-temp", - "version": "1.2.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@base-ui/react": "^1.2.0", diff --git a/package.json b/package.json index be01d91..0d4d30a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frontend-temp", "private": true, - "version": "1.3.0", + "version": "2.0.0", "type": "module", "license": "MIT", "author": "Kevin Riehl",