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.
74 lines
3.6 KiB
Bash
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}"
|