harden(docker)!: run nginx unprivileged and stop dropping security headers

BREAKING CHANGE: the production container listens on 8080 instead of 80.
nginx now runs as an unprivileged user and 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,
traefik.http.services.movieloop.loadbalancer.server.port 80 -> 8080.

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.
Both /assets/ and /config.js declared one, so X-Frame-Options,
X-Content-Type-Options and Referrer-Policy were silently absent from
every JavaScript and CSS response. The headers now come from a snippet
included in each location - verified 6/6 on /, on a hashed asset bundle,
and on /config.js.

Adds a CSP, HSTS and Permissions-Policy. connect-src is derived at
container start from API_URL, because the API is deliberately not
same-origin in production; asserting same-origin would have broken the
deployment outright. The inline theme script is allowed by SHA-256 hash
rather than unsafe-inline, which would have bought almost nothing -
editing that script means regenerating the hash.

config.js is also no longer rendered with envsubst, which does no
escaping: an API_URL containing a double quote closed the JS string
literal and the remainder executed on every page load.

Addresses M31, M34.
This commit is contained in:
TehRiehlDeal committed 2026-09-10 22:51:03 -07:00
1 parent 92d7dfdef3
commit ca3862d5a7
6 files changed
+145 -17

No files matched your search

+54
View File
@@ -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.
+19 -4
View File
@@ -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.
+51 -3
View File
@@ -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 <<EOF
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 Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; object-src 'none'; script-src 'self' '${theme_script_hash}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:; connect-src ${connect_src}" always;
EOF
echo "[config] API_URL=${API_URL} -> /config.js"
echo "[config] CSP connect-src: ${connect_src}"
+18 -7
View File
@@ -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;
}
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend-temp",
"private": true,
"version": "1.3.0",
"version": "2.0.0",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",