Files
movieloop-frontend/docker/40-render-config.sh
T
TehRiehlDeal da32e49b0b
frontend-ci / lint (push) Successful in 24s
frontend-ci / test (push) Successful in 32s
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / test (pull_request) Successful in 34s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 13s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 26s
frontend-ci / build (pull_request) Successful in 48s
frontend-ci / push (pull_request) Skipped
fix: push subscription payload; CSP hash computed from the shipped page (2.2.3)
Enabling push in production failed with "Validation failed": the
browser's PushSubscription.toJSON() carries expirationTime, and it was
sent verbatim to a schema that is strict about unknown fields. The
client now sends exactly endpoint + keys. The test doubles had been
shaped like the schema rather than like a browser, which is why 1,112
tests said nothing.

The CSP hash for index.html's inline theme script was hardcoded in the
container's start-up script behind a comment asking for it to be
regenerated by hand, and claiming a test asserted it; none did. The
bytes are what the browser hashes, so an edit or a checkout with
different line endings would have silently blocked the page's own
script. The hash is now emitted by `npm run build` from the index.html
that is actually shipped, copied into the image, and read at start; the
container refuses to start without it. Verified against the built
image: header hash equals served-script hash.

For the record: a CSP console message in production is what led here,
and the served page turned out to be correct - that message was a
browser extension's injected script, which the policy is right to
refuse.
2026-09-12 10:22:14 -07:00

86 lines
4.3 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.
#
# The hash tokens are computed at build time from the index.html that is
# actually shipped (scripts/csp-inline-script-hashes.mjs, run by `npm run
# build`) and copied into the image by the Dockerfile. A hardcoded value used
# to live here, guarded by a comment asking for it to be regenerated by hand
# when the script changed; the bytes are what the browser hashes, so an edit
# or a different line ending would have silently blocked the page's own
# script. If the file is missing the build is broken, and refusing to start is
# better than serving a CSP that blocks the page.
hashes_file=/etc/nginx/csp-inline-script-hashes
if [ ! -s "$hashes_file" ]; then
echo "[config] ERROR: $hashes_file is missing or empty; the image was built without the CSP hash step" >&2
exit 1
fi
script_src_hashes=$(tr -d '\n\r' < "$hashes_file")
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' ${script_src_hashes}; 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}"
echo "[config] CSP script-src hashes: ${script_src_hashes}"