Skip to content

fix(security): headers for the API host, and the measurement that stopped the CSP hardening - #11213

Merged
MarkusNeusinger merged 5 commits into
mainfrom
infra/csp-hashes
Sep 3, 2026
Merged

MarkusNeusinger merged 5 commits into
mainfrom
infra/csp-hashes

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

The brief was "swap script-src 'unsafe-inline' for sha256 hashes, then walk the public pages with the console open". The walk is what changed the answer, so the measurement comes first.

The measurement: hash-only script-src breaks Cloudflare, not the SPA

app/index.html has three executable inline scripts (theme resolver, Eruda loader, Plausible stub) and three JSON-LD data blocks that need no hash. yarn build was verified to copy the three through byte-for-byte, so hashing the source file describes what nginx serves.

The candidate policy was then mounted over the live production bundle — a local proxy that mirrors https://anyplot.ai and re-stamps the response with each header set — and loaded twice in Chrome:

script-src Cloudflare's injected script console
'self' 'unsafe-inline' cdn.jsdelivr.net (today) runs — its hidden iframe is in the DOM clean of CSP
'self' <3 × sha256> cdn.jsdelivr.net blocked — no iframe Executing inline script violates the following Content Security Policy directive … The action has been blocked.

Exactly one script is blocked, and it is not ours. Cloudflare JavaScript Detections injects an inline script into every HTML response at the edge, after nginx:

window.__CF$cv$params={r:'a357b436fa1aa625',t:'MTc4ODQ2OTQzNQ=='};

The ray id and timestamp change per response, so its hash does too — it can never be listed. Confirmed the same script and iframe on https://anyplot.ai itself, so this is production behaviour, not a proxy artefact.

Shipping the hardened policy would have silently degraded bot detection on a site whose brand-new origin gate (#11208) explicitly leans on the edge. So it is not shipped, and — equally deliberately — the hashes are not added next to 'unsafe-inline' either: a browser ignores 'unsafe-inline' the moment a hash appears, so that combination is the identical breakage wearing a stricter-looking label. A test now forbids it.

The way out is a nonce, not a hash. Cloudflare documents that it parses this response header and stamps its injected script with the nonce it finds, and recommends that over 'unsafe-inline'. That needs nginx to mint one per request and rewrite index.html's <script> tags (sub_filter plus gzip_static off for the shell) — a delivery change no test in this repo can prove and no local nginx here can run. Owner call, and the alternative (turning JavaScript Detections off in the zone) is a security trade rather than a fix. The reasoning, the numbers and the three hashes sit in security-headers.conf at the directive they explain, so the switch is a one-line edit whenever one of the two happens.

What does ship

The API host stamps its own baseline headers. api.anyplot.ai is a separate origin with no nginx in front of it, so it inherited none of app/security-headers.conf — only /proxy/html set nosniff and a Referrer-Policy, by hand, on that one response. An outermost middleware now setdefaults both on every response, including CORS preflights, the origin gate's 403 and the exception handlers' 500s. Deliberately not X-Frame-Options: the SPA embeds /proxy/html cross-origin in an iframe (frame-src https://api.anyplot.ai), and SAMEORIGIN would break every interactive plot preview — the test asserts its absence so nobody adds it as an obvious-looking improvement.

Both /_health locations stop dropping the site's headers. They set an add_header of their own, and nginx drops every inherited header in such a location — the rule stated at the top of security-headers.conf, and the one place in nginx.conf that had missed it. Found by the new test, verified against origin/main:

locations missing the include on origin/main: ['location /_health {', 'location /_health {']
after the fix: []

tests/unit/api/test_csp_policy.py — six checks over files that nothing else compiles or imports: the reserve hashes still match index.html; script-src never mixes 'unsafe-inline' with a hash; object-src 'none' and base-uri 'self' stay closed; the policy never carries report-to beside report-uri (measured in the sibling repo: Chromium then reports nothing at all — neither is set here today, so this exists for whoever adds reporting); every nginx location with its own header re-includes the snippet; and the API host's two headers are present while X-Frame-Options is not.

One anyplot-specific trap it had to handle: index.html documents its own Eruda loader with the words Plain <script> (not type="module") inside an HTML comment, and a script regex that reads that as a tag hashes the comment prose instead of the script — silently, with a hash that looks perfectly plausible. Comments are stripped first.

Items from the brief that turned out to be no-ops here

  • report-to beside report-uri — anyplot's CSP has neither. Encoded as a test instead of a fix.
  • bluetooth=() in the Permissions-Policy — anyplot sends no Permissions-Policy at all. Nothing to remove. Adding one is a separate, easy hardening pass; not folded in here.

Verification

pytest tests/unit tests/integration — 1913 passed, 1 skipped. ruff check, ruff format --check and mypy api core clean. The CSP walk itself is the table above, run against the live bundle in Chrome.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

… before hardening it

api.anyplot.ai is a separate origin with no nginx in front of it, so it
inherited nothing from app/security-headers.conf — only /proxy/html set nosniff
and a Referrer-Policy, on that one response. An outermost middleware now
setdefaults both on every response. NOT X-Frame-Options: the SPA embeds
/proxy/html cross-origin in an iframe, and SAMEORIGIN would break every
interactive preview.

Both /_health locations set an add_header without re-including the snippet, and
nginx drops every inherited header in such a location — the rule the file
states at the top, and the one place that had missed it.

tests/unit/api/test_csp_policy.py found that one, and pins the rest: object-src
and base-uri stay closed, report-to never sits beside report-uri (Chromium then
reports nothing), the API headers are present and X-Frame-Options is not, and
the sha256 hashes the policy holds in reserve still describe index.html's
inline scripts.

In reserve, not in force, for a measured reason. Mounted over the live
production bundle through a local proxy, a hash-only script-src blocks exactly
one script: the inline one Cloudflare JavaScript Detections injects at the
edge, whose body carries a per-response ray id and therefore has no fixed hash.
With 'unsafe-inline' its hidden iframe appears; with hashes it does not, and
the console reads "The action has been blocked". Hardening would have silently
cost bot detection on a site whose origin gate leans on the edge. The way out
is a nonce — Cloudflare stamps its injected script with the nonce it parses
from this header — which needs an nginx sub_filter no test here can prove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Copilot AI balanced review requested due to automatic review settings September 3, 2026 21:09
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Comment thread tests/unit/api/test_csp_policy.py Fixed
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

API 500 responses remain uncovered, and the CSP reporting guard rejects a valid configuration.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds API and nginx security headers while documenting why CSP hash enforcement remains deferred.

Changes:

  • Adds API-wide baseline headers.
  • Restores headers on nginx health endpoints.
  • Adds security-policy regression tests and documentation.
File summaries
File Description
tests/unit/api/test_csp_policy.py Adds policy tests, including an incorrect report-to/report-uri exclusion.
CHANGELOG.md Records changes but repeats the incorrect CSP reporting claim.
app/security-headers.conf Documents CSP findings and reserved hashes.
app/nginx.conf Restores security headers on health responses.
api/main.py Adds header middleware, but misses unhandled 500 responses.
Review details

Suppressed comments (1)

CHANGELOG.md:153

  • This repeats the incorrect claim enforced by the new test: Chromium prefers report-to when both directives exist, while report-uri can validly remain as a legacy fallback. The observed silence points to an unusable or missing reporting endpoint configuration, not to the two CSP directives coexisting; update this entry after correcting the guard.
  `tests/unit/api/test_csp_policy.py`, which also pins that the CSP keeps `object-src
  'none'` and `base-uri 'self'`, that it never carries `report-to` beside `report-uri`
  (measured in the sibling repo: Chromium then reports nothing at all), and that the
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread api/main.py Outdated
Comment thread tests/unit/api/test_csp_policy.py Outdated
CodeQL py/bad-tag-filter: `</script>` misses `</script >`, which HTML
permits — and a missed close swallows the rest of the document into one script
body and hashes that. This parses a file people edit, so the strictness is
earned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Copilot AI review requested due to automatic review settings September 3, 2026 21:19
Comment thread tests/unit/api/test_csp_policy.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Fix unhandled-500 header coverage and correct the CSP reporting and script parsing tests.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

CHANGELOG.md:145

  • This changelog claim overstates the middleware's coverage: an unhandled exception is converted to a 500 by Starlette's outer ServerErrorMiddleware, after this user middleware unwinds, so that response does not receive these headers. Keep this wording only after the header logic wraps the final ASGI app outside FastAPI.

CHANGELOG.md:153

  • This repeats the incorrect conclusion that merely placing report-to beside report-uri disables reporting. Standards-compatible deployments commonly send both: supporting browsers use report-to, and older browsers fall back to report-uri. Remove this claim or describe the actual missing/invalid endpoint configuration that caused the measured failure.
  `tests/unit/api/test_csp_policy.py`, which also pins that the CSP keeps `object-src
  'none'` and `base-uri 'self'`, that it never carries `report-to` beside `report-uri`
  (measured in the sibling repo: Chromium then reports nothing at all), and that the

api/main.py:315

  • Unhandled exceptions do not return from call_next: Starlette's always-outer ServerErrorMiddleware invokes the registered Exception handler and sends that 500 outside all user middleware. Therefore these headers are still absent from the unexpected-500 responses this change explicitly claims to cover. Apply the header mutation in an ASGI wrapper around the finished app (or directly in the generic handler), and add a route that raises with raise_server_exceptions=False to pin this path.
    response: Response = await call_next(request)
    response.headers.setdefault("X-Content-Type-Options", "nosniff")
    response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")

tests/unit/api/test_csp_policy.py:177

  • Coexistence does not suppress CSP reports. Supporting browsers use report-to and ignore report-uri, while browsers without report-to support fall back to report-uri; carrying both is the standard cross-browser migration pattern. A Chromium run that emitted nothing indicates an unconfigured or invalid reporting group, not a conflict between these directives. This assertion would reject the compatible setup and force a reporting gap; instead validate that any report-to group has a matching reporting-endpoint configuration.
    directives = csp_directives()
    assert not ("report-to" in directives and "report-uri" in directives), (
        "CSP carries both report-to and report-uri — Chromium then reports nothing. Keep one."
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tests/unit/api/test_csp_policy.py Outdated
MarkusNeusinger and others added 2 commits September 3, 2026 23:34
CodeQL again, and again right: after `</script` a browser skips to the first
`>`, so `</script\t\n bar>` closes the element too. The lookahead keeps
`</scriptfoo>` from counting as one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Three Copilot findings, all real.

1. ServerErrorMiddleware wraps every user middleware, so a route that raises
   makes `await call_next` raise with it and the Exception handler's 500 is
   built OUTSIDE the stack — the middleware's claim to cover it was wrong. The
   two headers now live in api/security_headers.py and both exits call the same
   `stamp`; a test registers a raising route on the real app and asserts the 500
   carries them.

2. The report-to/report-uri mutual exclusion rejected a legitimate migration:
   Chromium prefers report-to and keeps report-uri as the fallback for clients
   without it. Replaced by the check that actually catches silence — a
   `report-to <group>` must be defined by a Reporting-Endpoints header, because
   reports to an undeclared group go nowhere and nowhere reads exactly like
   "no violations".

3. `"src=" in attrs` misclassified `<script SRC = "…">` as inline and would have
   demanded a hash for a script with no body. Now `\bsrc\s*=`, case-insensitive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
@MarkusNeusinger
MarkusNeusinger merged commit 4b32715 into main Sep 3, 2026
16 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the infra/csp-hashes branch September 3, 2026 22:00
MarkusNeusinger added a commit that referenced this pull request Sep 4, 2026
…nonce (#11220)

The open item #11213 left behind, closed the way that PR said it had to
be: a nonce, not a hash.

## Why a nonce is the only thing that can work here

`app/index.html` ships three executable inline scripts. A FOURTH 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:

```js
window.__CF$cv$params={r:'a35d48a2bdf1be85',t:'MTc4ODUyNzk0NA=='};…
```

A body that differs per response has no hash to list, which is what
#11213 measured and why the hash policy was not shipped. Turning
JavaScript Detections off is not available either: the Free plan rejects
`enable_js=false` while Bot Fight Mode is on.

**Verified against Cloudflare's current docs** ([JavaScript
Detections](https://developers.cloudflare.com/bots/additional-configurations/javascript-detections/),
read 2026-09-04, and the [bots reference
page](https://developers.cloudflare.com/bots/reference/javascript-detections/),
last updated 2026-08-26):

> "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."

> "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 `<meta>` tags"), and `/cdn-cgi/challenge-platform/` must
be reachable — `script-src 'self'` covers it, same-origin. Two side
effects the docs name are already true of this setup: JSD strips `ETag`
from injected responses (we clear it anyway, below), and it skips
injection entirely on `Cache-Control: no-transform`, which we do not
send.

## The mechanism

`$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 128 bits is at the ceiling of what the spec asks for.

- **Header** (`app/security-headers.conf`): `script-src 'self'
'nonce-$request_id' https://cdn.jsdelivr.net`. `'unsafe-inline'` is
gone, not kept beside it — a browser ignores it the moment a nonce
appears, so the pair is the strict policy wearing a permissive label.
- **Stamp** (`app/nginx.conf`): `sub_filter '<script' '<script
nonce="$request_id"'` with `sub_filter_once off`, at **server level in
both server blocks**. Not per-location, because four locations can end
up serving the shell — the exact `= /index.html`, the SPA fallback, and
in the `python.anyplot.ai` block two regex routes whose `try_files
/index.html =404` serves the file *in place*, with no internal redirect
to re-run location matching. A per-location stamp is a stamp missing
from whichever one is forgotten, failing on those routes alone.
- **Not precompressed** (`app/vite.config.ts`): `index.html` is excluded
from both compression plugins. `gzip_static on` would hand out
`index.html.gz` byte for byte and `sub_filter` — which only ever sees
uncompressed bodies — would silently skip the stamp while the header
still demanded it. The hashed chunks keep their `.gz`/`.br`; the shell
is 11 kB and nginx gzips it on the fly.
- **Never replayed**: every location that answers with the shell sends
`no-store` — the exact `= /index.html` in each server block *and* the
two python-host spec routes, which the review caught serving a nonced
shell with no `Cache-Control` at all (see below) — and `sub_filter`
clears `Last-Modified` and `ETag` on its own whenever it rewrites a
body. Without that, a 304 would replace the stored headers with a fresh
nonce while the stored body still carried the old one — the classic
nonce-plus-cache failure. Verified below: neither validator is present.

`'strict-dynamic'` is deliberately **not** adopted. The reason is
narrower than it first looks, and the review sharpened it: the keyword
makes the browser ignore `'self'` for scripts, but the entry module is
itself a nonced `<script>`, so it runs and the imports it fetches run
with it. What breaks is one layer up — `yarn build` links every chunk
from the shell with `<link rel="modulepreload">`, and a link element is
neither something trust propagation reaches nor something a
`<script`-only stamp touches. Those hints are refused, which costs a
console full of violations and a slower first paint, not a blank page. A
cost with nothing bought, since the chunks are fingerprinted files under
`'self'` already. So the chunks stay on `'self'`, and a test requires
the stamp to be widened to `<link>` by whoever adopts the keyword.

## Local verification, behind the real config

nginx 1.24 with `http_sub_module`, running `app/nginx.conf` itself (only
the include path, docroot and port rewritten) over a real `yarn build`:

```
req1 header nonce: nonce-0dc9d1209b7ed536aca2366495392bf0
req2 header nonce: nonce-c608aa902ce6cbe622e659f901c6f326   ← different per request

script tags total: 7, without a nonce: 0
distinct nonces in one response: 1; length: 32
header nonce == tag nonce:  MATCH

stamped tags, SPA fallback deep route: 7
stamped tags, exact /index.html:       7
stamped tags, vhost spec route:        7    ← the try_files-in-place path
stamped tags, vhost spec/library:      7
stamped tags, vhost reserved route:    7

/assets/index-*.js  content-encoding: gzip, nonce occurrences: 0   ← precompressed, untouched
shell with Accept-Encoding: gzip → still 7 stamped tags
etag/last-modified present: 0
index.html.gz / index.html.br in dist: absent; assets/*.gz: present
```

`sub_filter` comes from `ngx_http_sub_module`, which is not compiled in
by default, so it is worth having checked rather than assumed:
`nginxinc/nginx-unprivileged:alpine` installs the prebuilt nginx package
from nginx.org's Alpine repository, and that binary's own configure line
carries `--with-http_sub_module` (unpacked `nginx-1.31.5-r1.apk` and
read the string out of `usr/sbin/nginx`). Had it been missing, nginx
would have refused to start on "unknown directive" — caught by the
candidate, but only there.

The one cosmetic finding on the way: `sub_filter` is a literal string
match, so the HTML comment that documented the Eruda loader with the
words `Plain <script>` came back carrying a stray `nonce=` inside a
comment. Harmless, but confusing in exactly the artefact anyone
debugging CSP reads, so the comment now describes the tag instead of
spelling it.

## Guards, so this cannot rot quietly

`tests/unit/api/test_csp_policy.py` — the hash test is retired with the
hashes (they guarded a reserve that no longer exists), and six checks
take its place. Every one was **mutation-checked**: each mutation below
was applied to a copy and the named test was confirmed to fail.

| Mutation | Caught by |
|---|---|
| stamp renamed to `$connection` |
`test_the_header_and_the_stamp_name_the_same_variable` |
| `sub_filter` removed from the python server block |
`test_every_server_block_stamps_the_nonce` |
| `sub_filter_once off` dropped | same |
| the vite `exclude` removed | `test_the_shell_is_never_precompressed` |
| `'unsafe-inline'` put back beside the nonce |
`test_script_src_never_mixes_unsafe_inline_with_a_nonce_or_hash` |
| nonce frozen to a literal |
`test_the_policy_takes_its_nonce_from_a_per_request_variable` |
| nonce dropped from the header | that one **and** the same-variable
test |
| `'strict-dynamic'` added without widening the stamp |
`test_strict_dynamic_would_have_to_widen_the_stamp_to_the_module_preloads`
|
| `no-store` removed from the exact `= /index.html` |
`test_the_shell_is_never_stored` |
| `no-store` removed from ONE python-host spec route | same |
| a python-host spec route reverted to a bare `try_files` | same |

That exercise found a real defect in the first draft:
`header_nonce_variable()` read the nonce with a regex over the whole
conf file, and `security-headers.conf` quotes `'nonce-…'` in its own
prose — so with the nonce deleted from the actual directive the test
still "found" one and passed. It now reads the parsed directive.

**And the deploy smoke refuses to promote without it.**
`app/cloudbuild.yaml` now fetches the candidate's shell, pulls the nonce
out of its `Content-Security-Policy` header, and requires that **every**
`<script>` tag carries that exact value. This is the failure with no
other symptom: the page still arrives, still has its `<div id="root">`,
still passes every existing probe — and runs no inline script at all.

That probe was run four ways against local nginx, using the step's own
bash with Cloud Build's `$$` resolved. Against the config in this PR:

```
OK: all 7 script tags of the shell carry the header's nonce      exit 0
```

against the same config with the two `sub_filter` directives deleted:

```
0 of 7 <script> tag(s) carry the header's nonce-0cfb5ee4…        exit 1
```

against a config that stamps a *different* variable than the header —
the case review round one showed the probe waving through, because it
counted `nonce=` attributes instead of comparing them:

```
0 of 7 <script> tag(s) carry the header's nonce-2a28032d…        exit 1
```

and with an `index.html.gz` planted back into the docroot, which is
round two's finding and the one that mattered most: plain `curl` sends
no `Accept-Encoding`, so `gzip_static` never reaches for the `.gz` and
the probe was blind to the exact regression it exists for.

```
plain curl         7 stamped tags   → passed
curl --compressed  0 stamped tags   → what a browser gets
```

With `--compressed` on that fetch it fails on the planted file (`0 of 7
… nonce-ffb48a58…`, exit 1) and passes once the `.gz` is gone.

## Rollback

Under a minute, no rebuild, back to today's policy byte for byte:

```bash
gcloud run revisions list --service anyplot-app --region europe-west4 \
  --format='table(name, creationTimestamp, status.conditions[0].status)' --limit 10

gcloud run services update-traffic anyplot-app --region europe-west4 \
  --to-revisions=<chosen-revision>=100
```

Pick the target by creation time, not by position — "the previous
revision" is the pre-nonce one only until the next frontend deploy,
which the review flagged. Once no pre-nonce revision is left, the lever
is a revert PR through the normal pipeline; acceptable, because by then
the live question has long been answered. Written down in
`agentic/docs/project-guide.md` § Rollback and, in short form, at the
directive it protects in `app/security-headers.conf`.

**A Cloud Run env var switch was considered and deliberately not
built**, and that is a deviation from the brief worth stating plainly.
nginx cannot read the process environment from its configuration, so an
env-var switch needs a startup templating step — `envsubst` into a
writable path, and `/etc/nginx` is read-only in
`nginxinc/nginx-unprivileged` (checked: no `chown`/`chmod g+w` on it,
final `USER 101`), so the image's own `NGINX_ENVSUBST_OUTPUT_DIR`
mechanism bails out with "not writable" and renders nothing. The
alternative is a hand-rolled entrypoint writing config into `/tmp`.
Either way its failure mode is "the container does not start", and there
is no Docker in this environment and no CI job that builds this image,
so nothing could exercise it before it mattered. The traffic split has
the same latency, needs no new machinery, and the deploy pipeline proves
the previous revision healthy on every build.

## What must still be measured in production, and what it would mean

Whether Cloudflare actually stamps the nonce is not observable from here
— it depends on the CSP header the **edge** sees from the origin, so a
local proxy cannot fake it. After merge and deploy, on
`https://anyplot.ai` in a real browser: the JSD script present and
carrying the nonce, zero CSP violations on the landing page and a plot
page, Plausible events still firing, Bot Fight Mode still active.

One thing to look for specifically. The injected script creates a hidden
`about:blank` iframe and inserts a second inline script *into that
document*:

```js
var d=b.createElement('script');
d.innerHTML="window.__CF$cv$params={…};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';…";
```

An `about:blank` document inherits its embedder's CSP, so that inner
script needs the nonce too — and there is an [open Cloudflare community
report](https://community.cloudflare.com/t/javascript-detections-jsd-api-js-creates-srcdoc-iframe-without-csp-nonce-violat/920831)
that JSD does not propagate the nonce into that frame. If the outer
script is nonced and only the inner one is refused, that is an upstream
gap in a defence-in-depth signal and not a site regression; if the outer
script is refused too, Cloudflare is not honouring the nonce at all, and
the rollback above is the answer.

## Review rounds

Six rounds. Twelve findings taken, one rejected with sources, the last
round clean (0 comments, 0 open threads).

- **Round 1** (5): the `no-store` gap below; the smoke counting `nonce=`
instead of comparing it; the rollback's positional "previous revision";
`anyplot-backend` → `anyplot-api`; and the 128-bit overstatement.
- **Round 2** (2): the deploy probe did not ask for compression, so
`gzip_static` never reached for a `.gz` and it could not see the
precompressed-shell regression it exists for — the sharpest catch of the
six, reproduced above. Plus the rollback as a numbered procedure per the
repository's Google style.
- **Round 3** (1 taken, 1 rejected): the entropy wording repeated in the
test. Rejected, with sources: that a nonce cannot authorize a `<link
rel="modulepreload">` — see the comment thread; four frameworks ship
precisely that fix.
- **Round 4** (2): `nonce-[0-9a-f]*` accepted a bare `nonce-` that `test
-n` calls non-empty, so an empty nonce would have agreed with `nonce=""`
tags all the way through; and the precompression guard now RUNS the
declared pattern against `index.html` instead of reading it for the word
"index", after three near-misses in two rounds showed the reading
approach was the wrong shape.
- **Round 5** (2): `test_every_server_block_stamps_the_nonce` searched
the whole block, nested locations included — so moving the stamp into
`location = /index.html` would have satisfied the guard that exists to
refuse exactly that, and the smoke wouldn't have caught it either since
it probes `/` on the main host. It now cuts nested locations out first.
Plus "two extra curls" where the probe adds one.
- **Round 6**: clean.

The one worth naming in full is round one's, a genuine hole the local
probes had walked straight past: `python.anyplot.ai/<spec>` and
`/<spec>/<library>` serve the shell through `try_files /index.html
=404`, which treats the shell as a FILE and answers **inside that
location** — no internal redirect, so `location = /index.html` and its
`no-store` are never reached. Measured before the fix:

```
$ curl -sI -H "Host: python.anyplot.ai" .../scatter-basic | grep -i cache-control
(nothing)
$ curl -sI .../scatter-basic | grep -i cache-control
Cache-Control: no-cache, no-store, must-revalidate
```

A nonced shell a client may keep. Both routes now send the shell's
`Cache-Control` and re-include `security-headers.conf` beside it (an
`add_header` of their own would otherwise have dropped the entire
inherited set — verified CSP, HSTS and X-Frame-Options are still on
those responses). And the test no longer looks for `location =
/index.html` by name: it derives the shell locations from the
`try_files` argument positions, finds all four, and fails if any one
loses `no-store`.

Verification: `pytest tests/unit tests/integration` — 1974 passed. `ruff
check`, `ruff format --check`, `mypy api core` clean. `yarn type-check`,
`yarn lint`, `yarn fm:check` clean, `yarn test` — 626 passed in 70
files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants