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:
1 parent
92d7dfdef3
commit
ca3862d5a7
6 files changed
+145
-17
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend-temp",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Kevin Riehl",
|
||||
|
||||
Reference in new issue
Block a user