Files
movieloop-frontend/docker/40-render-config.sh
T
TehRiehlDeal ca3862d5a7 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.
2026-09-10 22:51:03 -07:00

74 lines
3.6 KiB
Bash

#!/bin/sh
# 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
# everything after it became executable JavaScript on every page load. Operator
# supplied, so not a likely attack - but it is one typo away from a baffling
# breakage, and the page then hands its auth token to whatever origin the
# value resolves to.
#
# One substitution prefixes every backslash and double quote with a backslash;
# doing both in a single pass avoids the ordering trap where escaping quotes
# first would then double-escape the backslashes just added. Newlines are
# dropped afterwards since neither can appear in a JS string literal.
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}"