From 3e058af40a5bf6a2b676130a8149cc1b489d2173 Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Fri, 4 Sep 2026 15:40:10 +0200
Subject: [PATCH 1/8] fix(security): swap script-src 'unsafe-inline' for a
per-request nonce
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hash version was built, measured and rejected a day earlier (#11213):
Cloudflare JavaScript Detections injects an inline script at the edge whose
body carries a per-response ray id, so it has no hash that can be listed, and
a hash-only script-src blocked exactly that one script. Cloudflare documents
the way out — it copies a nonce out of the response header onto the script it
injects — and that is what this ships.
nginx mints the nonce from $request_id (16 random bytes as 32 hex digits, a
subset of the CSP nonce charset), sends it in the policy and stamps the same
value onto every
+ ships to normal users. A classic script element (not type="module") so
+ it runs before module parse-time errors and can capture them — critical
+ for debugging mobile-only crashes where remote DevTools aren't
+ available. The opening tag is described rather than written out: nginx
+ stamps the CSP nonce with a blunt `sub_filter` over the literal string,
+ so a comment containing it comes back to the browser with a stray
+ `nonce=` inside — harmless, but confusing in the one artefact anyone
+ debugging CSP will read. -->
`, and sub_filter's default type list is
+ # text/html alone, so this never reaches into a .js chunk or a JSON body.
+ # It does also pass over the proxied crawler pages from @seo_proxy, which
+ # carry no executable script of ours — one JSON-LD data block, which no
+ # browser executes — and crawlers run no JS either way.
+ sub_filter '`, so a single
-# re-indent invalidates one. The test recomputes them from index.html on every
-# run, which is what keeps this block honest while it waits.
+# Rolling back without a rebuild, if the edge ever stops honouring the nonce:
+# gcloud run services update-traffic anyplot-app --region europe-west4 \
+# --to-revisions==100
+# (`gcloud run revisions list --service anyplot-app --region europe-west4`
+# names them; every deploy gives its revision the deterministic suffix
+# `-b$BUILD_ID`, and the previous one still serves the 'unsafe-inline' policy
+# byte for byte.) agentic/docs/project-guide.md § Rollback has the long form.
# - style-src 'unsafe-inline': MUI/emotion inject inline styles.
# - img/font/connect storage.googleapis.com: plot previews + MonoLisa fonts on GCS.
# - img/connect/frame api.anyplot.ai: API calls, og images, interactive-preview
@@ -70,4 +97,4 @@ add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 180 days — moderate max-age, no includeSubDomains/preload (conservative first rollout).
add_header Strict-Transport-Security "max-age=15552000" always;
-add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://storage.googleapis.com https://api.anyplot.ai; font-src 'self' data: https://storage.googleapis.com; connect-src 'self' https://api.anyplot.ai https://storage.googleapis.com https://plausible.io https://api.github.com; frame-src 'self' https://api.anyplot.ai; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
+add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://storage.googleapis.com https://api.anyplot.ai; font-src 'self' data: https://storage.googleapis.com; connect-src 'self' https://api.anyplot.ai https://storage.googleapis.com https://plausible.io https://api.github.com; frame-src 'self' https://api.anyplot.ai; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 8425a540754..7e51c9d98d2 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -21,8 +21,16 @@ export default defineConfig({
overlay: { initialIsOpen: false },
enableBuild: false,
}),
- compression({ algorithm: 'gzip', threshold: 1024 }),
- compression({ algorithm: 'brotliCompress', threshold: 1024 }),
+ // index.html is EXCLUDED, and that is load-bearing rather than tidiness.
+ // nginx rewrites the shell per request to stamp the CSP nonce onto its
+ // `, and then
-# anything up to the first `>` — which is what a browser accepts and what
-# CodeQL's py/bad-tag-filter insists on (``, ``). A
-# regex that missed one of those would swallow the rest of the document into a
-# single "script body" and hash that, silently. The lookahead is what keeps
-# `` from counting as a close.
-_SCRIPT = re.compile(r"])[^>]*>", re.DOTALL | re.IGNORECASE)
-_TYPE = re.compile(r"""type\s*=\s*["']?([^"'\s>]+)""", re.IGNORECASE)
-# An EXTERNAL script, which CSP judges by its URL and never by a hash. HTML
-# attribute names are case-insensitive and whitespace around `=` is legal, so
-# `