diff --git a/agentic/docs/project-guide.md b/agentic/docs/project-guide.md index ca884a89af1..2cf09e7fe20 100644 --- a/agentic/docs/project-guide.md +++ b/agentic/docs/project-guide.md @@ -1065,6 +1065,47 @@ place, and the residual is narrow: it needs the previous candidate to pass every well, so the worst case is promoting a revision that was believed smoked, not shipping a page known to be broken. +### Rollback + +Every deploy names its revision deterministically (`--revision-suffix=b$BUILD_ID`) and +promotes it explicitly, so earlier revisions are still there, still healthy, and one +command away. Rolling back needs no rebuild and takes under a minute: + +1. List the recent revisions with their creation times: + + ```bash + gcloud run revisions list --service anyplot-app --region europe-west4 \ + --format='table(name, creationTimestamp, status.conditions[0].status)' --limit 10 + ``` + +2. Send all traffic to the revision you want back: + + ```bash + gcloud run services update-traffic anyplot-app --region europe-west4 \ + --to-revisions==100 + ``` + +Same shape for `anyplot-api`. This is the lever for anything that ships inside the image +and only reveals itself in production. + +**Pick the target by what it contains, not by its position.** "The previous revision" is +the right answer only while the change you are undoing is the most recent deploy; one more +deploy later, the previous revision carries it too. The creation timestamps in step 1 are +there for that — find the last revision created before the change landed. The current +example is the CSP nonce path in `app/security-headers.conf`: if Cloudflare ever stops +stamping its edge-injected script with the nonce it reads from our response header, any +revision from before that PR still serves the `'unsafe-inline'` policy byte for byte. +Once no such revision is left, the lever is a revert PR through the normal pipeline +instead — slower, but by then the question has long been answered live. + +One thing is deliberately not built for that case: a Cloud Run environment variable that flips the +policy inside a running revision. nginx cannot read the process environment from its +configuration, so it would take a startup templating step (`envsubst` into a writable +path — and `/etc/nginx` is read-only in the unprivileged image), which neither CI nor a +local checkout here can exercise. A mechanism whose failure mode is "the container does +not start", and which nothing can test, is a worse rollback than a traffic split the +deploy pipeline already proves on every build. + ## Debugging Tips ### Database Connection Issues diff --git a/app/cloudbuild.yaml b/app/cloudbuild.yaml index 81e86d956de..f271493d962 100644 --- a/app/cloudbuild.yaml +++ b/app/cloudbuild.yaml @@ -158,6 +158,43 @@ steps: } # Humans get the SPA shell. expect "$$HUMAN" "/" '
' "a browser did not get the SPA shell" + # …and 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 '.gz` untouched when it exists, which is exactly what + # sub_filter cannot rewrite. index.html is therefore excluded from + # precompression in app/vite.config.ts — the nonce depends on it. gzip_static on; # Vite-fingerprinted assets — filename changes on every build, safe to cache forever @@ -380,6 +404,13 @@ server { # main server block above). include /etc/nginx/security-headers.conf; + # CSP nonce, same as the main server block — and this block is the reason + # it sits at server level: the two spec-route regex locations below serve + # `/index.html` through `try_files` as a FILE, so they never reach the + # `location = /index.html` block and would carry no stamp at all. + sub_filter ' hub on main domain (no language segment; canonical is /{spec_id}) + # + # `try_files /index.html =404` serves the shell as a FILE, in THIS location — + # no internal redirect, so `location = /index.html` and its headers are never + # reached. That is why the two routes below repeat the shell's Cache-Control + # instead of inheriting it: the shell now carries a per-request CSP nonce, and + # a stored copy would pair an old `nonce="…"` in the body with a fresh one in + # the header. Measured before the fix: these two routes answered with no + # Cache-Control at all while anyplot.ai/ answered `no-store`. location ~ "^/(?[A-Za-z0-9][A-Za-z0-9-]*)/?$" { set $python_seo_uri /seo-proxy/$spec_id; error_page 418 = @seo_proxy_python; if ($is_bot) { return 418; } + add_header Cache-Control "no-cache, no-store, must-revalidate"; + include /etc/nginx/security-headers.conf; try_files /index.html =404; } @@ -452,6 +493,8 @@ server { set $python_seo_uri /seo-proxy/$spec_id/python/$library; error_page 418 = @seo_proxy_python; if ($is_bot) { return 418; } + add_header Cache-Control "no-cache, no-store, must-revalidate"; + include /etc/nginx/security-headers.conf; try_files /index.html =404; } diff --git a/app/security-headers.conf b/app/security-headers.conf index 2e3c38e2b5f..64294834988 100644 --- a/app/security-headers.conf +++ b/app/security-headers.conf @@ -7,54 +7,100 @@ # location, re-include this file there. # # CSP notes (must not break the SPA — see app/index.html and app/src): -# - script-src 'unsafe-inline': index.html ships three executable inline +# - script-src 'nonce-$request_id': index.html ships three executable inline # scripts (theme resolver, Eruda loader, Plausible stub), and a FOURTH one # arrives that this repository does not write — see the block below. # - script-src cdn.jsdelivr.net: on-device debug console (Eruda) behind ?debug=1. +# Reached through a `src`, so the host is what CSP judges, not a nonce. # -# Why script-src still says 'unsafe-inline' (measured 2026-09-03) -# --------------------------------------------------------------- -# Replacing 'unsafe-inline' with the sha256 of each inline script is the -# obvious hardening — index.html is static, so its scripts are fixed at build -# time, and `yarn build` was verified to copy them through byte-for-byte. The -# three hashes are recorded below and pinned by tests/unit/api/test_csp_policy.py -# so they never go stale. +# Why script-src is a NONCE and no longer 'unsafe-inline' (2026-09-04) +# -------------------------------------------------------------------- +# The obvious hardening — replace 'unsafe-inline' with the sha256 of each +# inline script — was built, measured and rejected on 2026-09-03 (#11213). +# index.html is static, so its three hashes are stable, but a FOURTH inline +# script arrives after nginx: Cloudflare JavaScript Detections injects one into +# every HTML response at the edge, and its body carries a per-response ray id +# and timestamp: # -# They cannot be ENFORCED yet. Cloudflare JavaScript Detections injects an -# inline script into every HTML response at the edge, after nginx, and its body -# carries a per-response ray id and timestamp — so its hash differs on every -# request and cannot be listed here. The whole policy was mounted over the LIVE -# production bundle through a local proxy and loaded twice, once with each -# script-src: +# window.__CF$cv$params={r:'a35d48a2bdf1be85',t:'MTc4ODUyNzk0NA=='};… +# +# A body that differs per response has no fixed hash, so it can never be +# listed. Mounted over the live production bundle and loaded twice, the two +# policies measured: # # 'unsafe-inline' → Cloudflare's script runs (its hidden iframe appears) # hashes only → "Executing inline script violates … The action has # been blocked", no iframe, no JS-detection signal # -# Exactly one script is blocked, and it is the edge's. Shipping the hash policy -# would silently degrade bot detection on a site whose origin gate leans on the -# edge — so it is not shipped, and 'unsafe-inline' is NOT joined by hashes -# either: a browser ignores 'unsafe-inline' as soon as a hash is present, so the -# two together are the same breakage wearing a stricter-looking policy. +# Turning JavaScript Detections off is not the way out either: the Free plan +# refuses it while Bot Fight Mode is on (the API rejects `enable_js=false`), +# and it would be a security trade rather than a fix. +# +# A NONCE is the way out, and it is Cloudflare's own recommendation. Their +# JavaScript Detections page (developers.cloudflare.com/bots/ +# additional-configurations/javascript-detections/, read 2026-09-04) says: "If +# your CSP uses a `nonce` for script tags, Cloudflare will add these nonces to +# the scripts it injects by parsing your CSP response header", and "We highly +# discourage the use of `unsafe-inline` and instead recommend the use CSP +# `nonces` in script tags which we parse and support in our CDN." Two +# conditions come with it, and both hold here: the nonce must arrive in the +# response HEADER (JavaScript Detections "is not supported with `nonce` set via +# `` tags" — this file is the header), and `/cdn-cgi/challenge-platform/` +# must be reachable, which `script-src 'self'` covers because it is same-origin. +# +# The nonce is `$request_id`: nginx's own 16 random bytes, rendered as 32 hex +# digits. Hex is a subset of the base64-value charset the CSP nonce grammar +# accepts, and 16 bytes is exactly the 128 bits of entropy CSP recommends for a +# nonce (a recommendation in the spec, not a requirement). nginx stamps that +# value on every `, so it +# runs, and its imports are fetched by a script rather than the parser, so they +# run too. What breaks is the layer above them: Vite's shell links every chunk +# with ``, and a link element is not something +# strict-dynamic's trust propagation reaches. So the hints are refused — a +# console full of violations and a slower first paint, because each chunk then +# waits for the entry module to ask for it, instead of a blank page. Not a +# catastrophe; just a cost with nothing bought, since the chunks are +# fingerprinted files under 'self' already. # -# The way out is a NONCE, not a hash. Cloudflare parses this response header -# and stamps its own injected script with the nonce it finds there (their -# JavaScript Detections docs say so explicitly, and recommend it over -# 'unsafe-inline'). That needs nginx to mint one per request and rewrite -# index.html's ``, 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 — +# pick the last revision created BEFORE this policy shipped, which is NOT +# "the previous one" once another frontend deploy has happened (Copilot review): +# gcloud run revisions list --service anyplot-app --region europe-west4 \ +# --format='table(name, creationTimestamp)' --limit 10 +# gcloud run services update-traffic anyplot-app --region europe-west4 \ +# --to-revisions==100 +# Once this has been live long enough that no pre-nonce revision is left, the +# lever is a revert PR through the normal pipeline instead — which is fine, +# because by then the question this policy risks has been answered. +# 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 +116,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 + // `, ``). 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"[^>]*)>(?P.*?)])[^>]*>", 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 -# `