A per-request CSP nonce for the script Cloudflare injects at the edge
- Status: Accepted
- Date: 2026-09-06
- Deciders: Engineering, Security
Context
Section titled “Context”ADR 0015 made the
Content-Security-Policy fully strict: every inline script and style the site
emits is SHA-256 hash-pinned at build, and script-src carries no
'unsafe-*' expression. That policy governs the document, and the document
is not entirely ours. The zone runs Cloudflare Bot Fight Mode (the Free
plan’s bot product), under which JavaScript Detections is always on and
cannot be switched off: Cloudflare injects a small inline bootstrap into
every HTML response at the edge, which loads
/cdn-cgi/challenge-platform/scripts/jsd/main.js and issues the visitor a
cf_clearance cookie. That cookie is what lets real visitors pass the
managed_challenge WAF rules on the form endpoints without seeing a
challenge.
The bootstrap’s body embeds the request’s ray id and a timestamp, so its hash
differs on every response and can never be pinned. Under the strict policy
the browser blocks it — one script-src violation in every visitor’s console,
and no JavaScript signal in Cloudflare’s scoring of our traffic. The WAF rules
keep working on IP reputation alone, which is part of why a CI runner’s
non-browser probes were answered with 403s (ADR 0018’s
smoke rework).
Cloudflare documents one supported mechanism: if the response’s
Content-Security-Policy header carries a 'nonce-…' source in
script-src, the edge parses it and stamps the nonce on the script it
injects. A nonce in a <meta> policy is not read, and 'unsafe-inline' is
discouraged by Cloudflare as much as by us.
ADR 0015 rejected per-request nonces because they “need a server in the
request path” and would route every page through Worker code with
HTMLRewriter to stamp nonces into the markup. The need here is narrower:
the site’s own scripts stay hash-pinned and the markup is untouched; only the
header needs a fresh value, for the one script we do not author.
Decision
Section titled “Decision”Every HTML response leaves the Worker with a per-request nonce appended to
script-src. The build-time hashes, the per-route-family rules, the <meta>
CSP in 404.html, and every other directive are unchanged.
apps/web/src/worker.tsis the Worker entry (maininwrangler.toml). It keeps the platform’s serving order — assets first, then the app — but performs it itself: a GET/HEAD is offered to the assets binding withredirect: 'manual', and whatever the asset layer decides is passed through verbatim (a page with its_headerspolicy, a_redirects301, the trailing-slash 307, a 304); only a 404 from the binding reaches the adapter’shandle. The adapter’s own asset fallback fetches with redirect-follow, which would turn a legacy 301 into a 200 at its target. Every response then passes throughapplyScriptNonce(src/lib/csp-nonce.ts): when it is HTML and carries a policy with ascript-srcdirective, a copy is returned with'nonce-<128 random bits, base64>'inserted into that directive. Non-HTML responses, and HTML without a policy, are returned as the same object. The body stream is passed through unread.- HTML is routed through the Worker.
run_worker_firstin theassetstable (production and staging alike) sends every request to the Worker except the asset families that never carry a policy —/_astro/*,/logos/*,/og/*, and the image, font, script, style, and data extensions. For a static page the adapter’s handler serves the file from the assets binding, so_headersstill supplies the per-family policy the nonce is added to; on-demand routes keep their middleware-set policy (ADR 0017 §2) and gain the nonce the same way. The site’s markup is not rewritten. - The contract is enforced three ways. Unit tests pin the pure
functions (
csp-nonce.test.ts: one nonce source, inscript-srconly, fresh per response, non-HTML untouched). The Playwrightworkerproject drives the built Worker underwrangler devand asserts the shape on a static page, a nested static route, the grievance page, and an asset, and that a legacy_redirects301 and the trailing-slash 307 still pass through (tests/e2e/csp-nonce.spec.ts). The post-deploy smoke asserts on the live site that the header carries a nonce, that the edge-injected bootstrap runs under it, and that the page raises noscript-srcviolation (tests/e2e/production.spec.ts). The same was verified on staging on 2026-09-06, ahead of the merge, with this version promoted there: a browser holding nocf_clearancecookie received the bootstrap carrying the header’s nonce, logged no violation, and was issued the cookie the detection script sets — so its next load was not injected at all. Bot Fight Mode skips the injection for a visitor who already holdscf_clearance, which is why a browser that has passed a managed challenge on the zone shows nothing either way.
Consequences
Section titled “Consequences”- The strict-CSP claim stays true and gains its last missing piece: the one script we do not author now runs under the policy instead of being silently blocked, visitors’ consoles are clean, and Cloudflare’s bot scoring sees the JavaScript signal it expects from our traffic.
- The nonce is per response, unpredictable, and appears nowhere in the
markup, so it grants nothing to markup injection: an attacker who can
inject a
<script>still does not know the value.'strict-dynamic'is still not used; the host allowlist keeps covering Turnstile and Cloudflare Insights. - HTML is no longer served as a pure static asset, and it does not
matter. Each HTML request costs one Worker invocation that fetches the
page from the assets binding and rewrites one header — no
HTMLRewriter, no body parsing. The page body still comes from the edge asset cache: the binding’s response carries the asset layer’s ownCF-Cache-Status: HITthrough to the visitor, with a fresh nonce beside it. Measured on 2026-09-06 against two unpromoted staging preview versions built from the same commit, one asset-first and one Worker-first, interleaved, 30 samples per cell: medians within a few milliseconds of each other in both directions on/,/work, and/contact, and identical best-case TTFB (58–62 ms). The zone cache rule for HTML documents (quarry-cache-rules, 5-minute TTL) was redundant with the asset cache before and stayed so — and in fact never applied at all: zone cache configuration has no effect on Worker responses, so that whole ruleset was retired on 2026-09-19 (ADR 0008). Assets under the negative patterns never touch the Worker and keep the asset layer’s own long-lived cache. This is the trade ADR 0015 declined for the whole document; for the header alone it has no measurable cost. wrangler.tomlnow setsmain. The adapter honours a user-set entry (main ?? '@astrojs/cloudflare/entrypoints/server'); the wrapper imports the samehandlethe default entry uses, so adapter upgrades continue to flow through unchanged. Therun_worker_firstlist is capped at 100 entries by Cloudflare; a new non-HTML asset family needs a negative entry or it merely costs a Worker invocation.- The post-deploy smoke is now a bot in Cloudflare’s eyes. The first production deploy of this decision (2026-09-06) rolled back: the nonce assertions passed, but with the detection script finally executing in the smoke’s headless Chromium, Bot Fight Mode classified the session as automated and answered its second navigation with a managed-challenge interstitial (403). A first visit in a fresh session passes, so the smoke probes each backbone route as its own test with its own context; every other smoke test already loads a single page. The Free plan cannot exempt the smoke — Bot Fight Mode ignores WAF skip rules, and GitHub-hosted runners have no stable egress for an IP Access Rule — so this holds only while the verdict is per session. If Cloudflare ever correlates it by IP, the proper exemption is a Super Bot Fight Mode skip rule keyed on a smoke header.
- The 404 copies served at unmatched paths are governed by the
<meta>CSP the injector writes into404.html, which the edge does not read; those responses keep today’s behaviour (the bootstrap is blocked there). They are not visitor pages.
Alternatives considered
Section titled “Alternatives considered”- Turn Bot Fight Mode off. Stops the injection with no code change, but
forfeits the automatic blocking and the
cf_clearancepath that spares real submitters a challenge. Kept as the fallback if the Worker path ever has to be withdrawn. - Super Bot Fight Mode (Pro plan) with JavaScript Detections disabled. Removes the injection while keeping bot verdicts, at a subscription and without the JavaScript signal. Answers a different question.
- A Cloudflare Transform Rule rewriting the header at the edge
(
http_response_headers_transformwithuuidv4(cf.random_seed)). The attractive option: HTML would stay on the asset path with its CDN cache hit, and the rule would live in Terraform. Tested on the live zone on 2026-09-06 with two temporary rules and then removed. The value expression can read the response’s own policy and the nonce is fresh on every cached hit — but the bot injection stamps nothing: the edge reads the policy as the Worker or asset layer returned it, before transform rules run, so a nonce added there is never seen by the script it is meant to cover. Two further constraints on the Free plan:regex_replaceneeds Business, anduuidv4may be called once per expression, so the only nonce shape available is a hyphenated UUID or a 48-bit slice of one. Rejected on the ordering alone; the same test shows a CDN-cached HTML response and a per-request nonce cannot coexist, because the injection would reuse the cached header’s nonce for every hit. 'unsafe-hashes', a constant nonce in_headers, or'unsafe-inline'. A constant nonce is public in every response, so it is'unsafe-inline'with extra steps; the others are the expressions ADR 0015 exists to exclude. Rejected.HTMLRewriternonces on our own scripts too (drop the hashes). The full strict-CSP-by-nonce model. It rewrites every document on every request for no gain over hashes on a static site, and it is exactly the cost ADR 0015 rejected. Not needed: hashes and a nonce coexist inscript-src.