fix(security): headers for the API host, and the measurement that stopped the CSP hardening - #11213
Conversation
… 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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
🟡 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-towhen both directives exist, whilereport-urican 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.
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
There was a problem hiding this comment.
🟡 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-tobesidereport-uridisables reporting. Standards-compatible deployments commonly send both: supporting browsers usereport-to, and older browsers fall back toreport-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-outerServerErrorMiddlewareinvokes the registeredExceptionhandler 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 withraise_server_exceptions=Falseto 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-toand ignorereport-uri, while browsers withoutreport-tosupport fall back toreport-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 anyreport-togroup 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
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
…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>
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-srcbreaks Cloudflare, not the SPAapp/index.htmlhas three executable inline scripts (theme resolver, Eruda loader, Plausible stub) and three JSON-LD data blocks that need no hash.yarn buildwas 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.aiand re-stamps the response with each header set — and loaded twice in Chrome:script-src'self' 'unsafe-inline' cdn.jsdelivr.net(today)'self' <3 × sha256> cdn.jsdelivr.netExecuting 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:
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.aiitself, 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_filterplusgzip_static offfor 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 insecurity-headers.confat 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.aiis a separate origin with no nginx in front of it, so it inherited none ofapp/security-headers.conf— only/proxy/htmlsetnosniffand aReferrer-Policy, by hand, on that one response. An outermost middleware nowsetdefaults both on every response, including CORS preflights, the origin gate's 403 and the exception handlers' 500s. Deliberately notX-Frame-Options: the SPA embeds/proxy/htmlcross-origin in an iframe (frame-src https://api.anyplot.ai), andSAMEORIGINwould break every interactive plot preview — the test asserts its absence so nobody adds it as an obvious-looking improvement.Both
/_healthlocations stop dropping the site's headers. They set anadd_headerof their own, and nginx drops every inherited header in such a location — the rule stated at the top ofsecurity-headers.conf, and the one place innginx.confthat had missed it. Found by the new test, verified againstorigin/main:tests/unit/api/test_csp_policy.py— six checks over files that nothing else compiles or imports: the reserve hashes still matchindex.html;script-srcnever mixes'unsafe-inline'with a hash;object-src 'none'andbase-uri 'self'stay closed; the policy never carriesreport-tobesidereport-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 whileX-Frame-Optionsis not.One anyplot-specific trap it had to handle:
index.htmldocuments its own Eruda loader with the wordsPlain <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-tobesidereport-uri— anyplot's CSP has neither. Encoded as a test instead of a fix.bluetooth=()in the Permissions-Policy — anyplot sends noPermissions-Policyat 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 --checkandmypy api coreclean. 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