diff --git a/.dockerignore b/.dockerignore index 82190f0..2e15c53 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ * !web/ !deploy/nginx.conf +!deploy/security-headers.conf diff --git a/Dockerfile b/Dockerfile index 994ca3c..d72576a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,10 +8,29 @@ # # The desktop app (PyQt5) is unaffected and not part of this image. +# ---- assets: version every file except the page by content ------------------ +# Everything but index.html goes under v//, and index.html's +# references are rewritten to match. Each deploy therefore changes every asset +# URL, so no cache (browser or Cloudflare, which forces .js/.css browser caching +# to 4 h) can pair a new index.html with an old app.js. Module imports and the +# worker URL are relative, so they resolve inside the versioned directory as is. +FROM alpine:3.22 AS assets +COPY web/ /src/ +RUN set -eu; \ + cd /src; \ + v="$(find . -type f ! -name index.html | sort | xargs sha256sum | sha256sum | cut -c1-12)"; \ + mkdir -p "/out/v/$v"; \ + for f in *; do [ "$f" = index.html ] || cp -R "$f" "/out/v/$v/"; done; \ + sed -E "s#(href|src)=\"(app\.css|app\.js|vendor/[^\"]+)\"#\1=\"v/$v/\2\"#g" index.html > /out/index.html; \ + grep -q "src=\"v/$v/app.js\"" /out/index.html; \ + grep -q "href=\"v/$v/app.css\"" /out/index.html + +# ---- runtime ------------------------------------------------------------------- FROM nginxinc/nginx-unprivileged:stable-alpine COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY web/ /usr/share/nginx/html/ +COPY deploy/security-headers.conf /etc/nginx/snippets/ujm-security-headers.conf +COPY --from=assets /out/ /usr/share/nginx/html/ EXPOSE 8080 diff --git a/deploy/nginx.conf b/deploy/nginx.conf index fd05788..9ecea8c 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,9 +1,10 @@ # Static server for the web UI (web/). # # The page decodes and analyses audio inside the visitor's browser. The CSP's -# `connect-src 'none'` forbids the page and its worker from making any network -# request (fetch, XHR, WebSocket, beacon), so dropped audio has no way to leave -# the tab — enforced by the browser, not just promised by the code. +# `connect-src 'none'` (security-headers.conf) forbids the page and its worker +# from making any network request (fetch, XHR, WebSocket, beacon), so dropped +# audio has no way to leave the tab — enforced by the browser, not just promised +# by the code. server { listen 8080; @@ -19,13 +20,20 @@ server { gzip on; gzip_types text/css application/javascript; - add_header Content-Security-Policy "default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; - add_header Referrer-Policy "no-referrer" always; - add_header X-Content-Type-Options "nosniff" always; - # Revalidate on every load so an update isn't stuck behind a proxy cache. - add_header Cache-Control "no-cache" always; - + # The page names the current asset version, so it is always revalidated. + # (Each location includes the security headers itself: nginx drops + # server-level add_header in any location that adds its own.) location / { + include /etc/nginx/snippets/ujm-security-headers.conf; + add_header Cache-Control "no-cache" always; try_files $uri $uri/ =404; } + + # Assets live under v// (see Dockerfile): a URL's content never + # changes, so it can be cached indefinitely. + location /v/ { + include /etc/nginx/snippets/ujm-security-headers.conf; + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } } diff --git a/deploy/security-headers.conf b/deploy/security-headers.conf new file mode 100644 index 0000000..20a3762 --- /dev/null +++ b/deploy/security-headers.conf @@ -0,0 +1,8 @@ +# Security headers for every response, included per location in nginx.conf. +# +# connect-src 'none': neither the page nor its Web Worker may make network +# requests, so audio cannot be sent anywhere. The worker's own policy comes from +# its script response, which is why every location carries these headers. +add_header Content-Security-Policy "default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; +add_header Referrer-Policy "no-referrer" always; +add_header X-Content-Type-Options "nosniff" always;