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]
|
## [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
|
## [1.3.0] - 2026-09-10
|
||||||
|
|
||||||
Session handling and effect correctness.
|
Session handling and effect correctness.
|
||||||
|
|||||||
+19
-4
@@ -21,15 +21,30 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# --- Production stage ---
|
# --- 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
|
# Pull current security fixes for OS packages inherited from the base image
|
||||||
# (e.g. nghttp2-libs CVE-2026-27135). envsubst is no longer installed:
|
# (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
|
# 40-render-config.sh writes config.js itself so it can escape the value, which
|
||||||
# envsubst cannot do.
|
# envsubst cannot do.
|
||||||
RUN apk upgrade --no-cache
|
RUN apk upgrade --no-cache
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY docker/40-render-config.sh /docker-entrypoint.d/40-render-config.sh
|
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
|
# The entrypoint script writes config.js and the security-header snippet at
|
||||||
# nginx:alpine's upstream entrypoint runs /docker-entrypoint.d/*.sh then launches nginx.
|
# 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
|
#!/bin/sh
|
||||||
# Render /config.js from the env var API_URL at container start.
|
# Renders the two things that depend on runtime configuration, before nginx
|
||||||
# Runs as part of the official nginx:alpine /docker-entrypoint.d sequence,
|
# starts: /config.js (the API base the app reads) and the security-header
|
||||||
# so nginx itself is launched by the upstream entrypoint after this script.
|
# 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
|
set -e
|
||||||
: "${API_URL:=/api/v1}"
|
: "${API_URL:=/api/v1}"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- config.js
|
||||||
# The value is interpolated into a JavaScript string literal, so it is escaped
|
# The value is interpolated into a JavaScript string literal, so it is escaped
|
||||||
# rather than substituted raw. This previously used envsubst, which does no
|
# 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
|
# 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" \
|
printf 'window.__APP_CONFIG__ = { apiUrl: "%s" };\n' "$escaped" \
|
||||||
> /usr/share/nginx/html/config.js
|
> /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] API_URL=${API_URL} -> /config.js"
|
||||||
|
echo "[config] CSP connect-src: ${connect_src}"
|
||||||
+18
-7
@@ -1,14 +1,22 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
# 8080, not 80: the server runs unprivileged and cannot bind a
|
||||||
listen [::]:80;
|
# privileged port without a capability that compose drops.
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Security headers
|
# Security headers are pulled in from a snippet rendered at container start
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
# (docker/40-render-config.sh), because the CSP has to name the API origin
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
# and that is only known from API_URL at runtime.
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
#
|
||||||
|
# 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 compression
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -17,18 +25,21 @@ server {
|
|||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
expires 1y;
|
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.
|
# Runtime config — rendered fresh on each container start. Never cache.
|
||||||
location = /config.js {
|
location = /config.js {
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
add_header Cache-Control "no-store" always;
|
add_header Cache-Control "no-store" always;
|
||||||
try_files /config.js =404;
|
try_files /config.js =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA fallback — serve index.html for all non-file routes
|
# SPA fallback — serve index.html for all non-file routes
|
||||||
location / {
|
location / {
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend-temp",
|
"name": "frontend-temp",
|
||||||
"version": "1.2.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend-temp",
|
"name": "frontend-temp",
|
||||||
"version": "1.2.0",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend-temp",
|
"name": "frontend-temp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Kevin Riehl",
|
"author": "Kevin Riehl",
|
||||||
|
|||||||
Reference in new issue
Block a user