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
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.
86 lines
4.3 KiB
Bash
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}"
|