Send a CSP and security headers from the frontend's nginx

script-src admits the bundle and the pre-paint theme script by hash, with no
unsafe-inline or unsafe-eval; style-src keeps unsafe-inline for the <style>
elements Monaco, Radix and the toasts insert. frame-ancestors 'self',
nosniff and strict-origin-when-cross-origin go with it, and the Dockerfile
adds the API origin to connect-src when the bundle calls one elsewhere. Zod
runs jitless so its eval probe is not reported on every page. The embed
widget keeps allow-same-origin: the demo's weather map does not render
without it; the comment now says so and what it costs.
This commit is contained in:
2026-10-06 12:30:47 +02:00
parent 3758415f21
commit 4dfb1e1206
7 changed files with 48 additions and 3 deletions
+7
View File
@@ -25,4 +25,11 @@ FROM nginx:1
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html
COPY ./frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY ./frontend/nginx-security-headers.conf /etc/nginx/security-headers.conf
# The CSP's connect-src names the API when the bundle calls it on another
# origin (https://api.x and wss://api.x); empty, nginx proxies it on this one.
ARG VITE_API_URL
RUN api="${VITE_API_URL%/}"; \
sed -i "s|__API_ORIGINS__|${api:+ $api $(echo "$api" | sed 's/^http/ws/')}|" \
/etc/nginx/security-headers.conf
COPY ./frontend/nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf
+3 -1
View File
@@ -11,7 +11,9 @@
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
<title>Fluksio</title>
<!-- Set the theme class before first paint so the app never flashes light. -->
<!-- Set the theme class before first paint so the app never flashes light.
The CSP admits this script by its hash (nginx-security-headers.conf):
change the script and that hash changes with it. -->
<script>
(() => {
try {
+14
View File
@@ -0,0 +1,14 @@
# Security headers. Included at server level and again in every location with
# an add_header of its own, since nginx drops the inherited ones there.
#
# script-src: the bundle, plus the theme script inline in index.html by its
# hash. Edit that script and this hash must change with it (sha256 of the text
# between the tags, base64), or the app paints light-then-dark again.
# style-src 'unsafe-inline': Monaco, Radix's scroll lock and the toasts insert
# <style> elements at runtime, which no hash can name.
# img/frame-src take any http(s): a camera stream or website widget points
# wherever its dashboard says. connect-src is this origin plus the API's when
# the bundle calls one elsewhere; the Dockerfile fills that in.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-FpmE/jXgh7LaQmoMHvqlYVa3hl7+cMNkDN2fSejs9So='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; media-src 'self' blob:; font-src 'self'; connect-src 'self'__API_ORIGINS__; worker-src 'self'; frame-src http: https:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+4
View File
@@ -24,6 +24,8 @@ server {
text/css
text/plain;
include /etc/nginx/security-headers.conf;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
@@ -35,6 +37,7 @@ server {
# on every reload.
location /assets/ {
root /usr/share/nginx/html;
include /etc/nginx/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
}
@@ -42,6 +45,7 @@ server {
# assets, so a stale copy pins the browser to the previous build.
location = /index.html {
root /usr/share/nginx/html;
include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache";
}
@@ -474,8 +474,12 @@ function EmbedWidget({ widget }: WidgetProps) {
<iframe
src={url}
title={widget.title || url}
// Same-origin is deliberately withheld: the page keeps its own storage
// and cookies only by being another origin, which it is.
// `allow-same-origin` keeps the page in its own origin, with the
// cookies and storage most sites will not start without: the demo's
// weather map renders an unstyled list without it. That is safe only
// while the page *is* another origin. One served from this app's own
// origin would run as the app, scripts and all, and could read the
// session.
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
referrerPolicy="no-referrer"
loading="lazy"
+12
View File
@@ -0,0 +1,12 @@
/**
* Zod without its `eval` probe.
*
* Zod compiles a faster object parser with `new Function` where it can, and
* finds out by trying once. Under the CSP (`frontend/nginx-security-headers.conf`)
* that try is reported as a violation on every page, although Zod catches it
* and falls back. `jitless` skips the probe and goes straight to the fallback.
* Imported first in `main.tsx`, since a schema built before it would probe.
*/
import { z } from "zod"
z.config({ jitless: true })
+2
View File
@@ -1,3 +1,5 @@
// First: it configures Zod before any module builds a schema.
import "./lib/zodConfig"
import {
MutationCache,
QueryCache,