Skip to content

fix(csp): match script-src-elem/attr when extracting script nonce - #2049

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
Divkix:fix/issue-1989
Jun 15, 2026
Merged

fix(csp): match script-src-elem/attr when extracting script nonce#2049
james-elicx merged 1 commit into
cloudflare:mainfrom
Divkix:fix/issue-1989

Conversation

@Divkix

@Divkix Divkix commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Next.js's get-script-nonce-from-header.tsx selects the nonce directive with dir.startsWith('script-src'), which also matches script-src-elem and script-src-attr. vinext used an exact-match predicate (matchesDirectiveName) that required directive === "script-src" or a script-src prefix, so a CSP declaring the nonce only on script-src-elem (with no plain script-src) was skipped. vinext then fell through to default-src (or found no nonce), and SSR scripts could be emitted without the expected nonce and get blocked by the browser CSP.

Fixes #1989.

Fix

Select the directive via startsWith("script-src") then startsWith("default-src"), matching Next.js exactly. The existing nonce-source parsing (slice + ESCAPE_REGEX guard) is unchanged. This is the single directive matcher in the repo, so App Router (app-rsc-handler.ts), Pages Router (pages-page-handler.ts), and dev (dev-server.ts) are all fixed together. The now-unused matchesDirectiveName helper was removed.

Next.js parity notes

  • Verified against canary packages/next/src/server/app-render/get-script-nonce-from-header.tsx, which uses directives.find(dir => dir.startsWith('script-src')) || directives.find(dir => dir.startsWith('default-src')).
  • No false-positive risk: only script-src, script-src-elem, script-src-attr start with script-src (all script-related); only default-src starts with default-src.
  • Faithful to Next.js's single-directive semantics: the nonce is extracted only from the first matching script-src* directive, so a nonce on a later plain script-src is not used.

Tests

Updated tests/csp.test.ts (the two prior tests encoded the old buggy behavior). Added coverage for:

  • script-src-elem 'nonce-abc' / script-src-attr 'nonce-attr' with no plain script-src → nonce extracted
  • First script-src* / default-src* directive selected when multiple exist
  • Edge case: nonce extracted only from the first script-src* directive (script-src-elem 'self'; script-src 'nonce-x'undefined)

Validation

  • vp test run tests/csp.test.ts — 15 passed
  • vp test run tests/nextjs-compat/script-nonce.test.ts — 4 passed (no SSR regression)
  • vp check — format, lint, typecheck clean

Select the CSP nonce directive via startsWith("script-src") /
startsWith("default-src"), matching Next.js get-script-nonce-from-header.
Previously the exact-match predicate skipped script-src-elem/script-src-attr
directives, so a CSP declaring the nonce only on script-src-elem fell through
to default-src and SSR scripts could be emitted without the expected nonce
and blocked by the browser CSP.

Closes cloudflare#1989
@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2049
npm i https://pkg.pr.new/vinext@2049

commit: 6ff2012

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot 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.

Review: LGTM ✅

The fix is correct, minimal, and faithfully matches Next.js. I verified the change against the current canary source of get-script-nonce-from-header.tsx:

const directive =
  directives.find((dir) => dir.startsWith('script-src')) ||
  directives.find((dir) => dir.startsWith('default-src'))

This PR now does exactly the same directive selection (startsWith("script-src") then startsWith("default-src")), which correctly resolves #1989: a CSP declaring the nonce only on script-src-elem/script-src-attr is now matched instead of falling through to default-src or no nonce.

Verified

  • Parity correct: directive selection matches Next.js canary exactly, including the single-directive semantics (nonce read only from the first matching script-src* directive). The test at tests/csp.test.ts:54 correctly encodes this (script-src-elem 'self'; script-src 'nonce-x'undefined), which is the same behavior Next.js exhibits.
  • Dead code removed cleanly: matchesDirectiveName had no other references in the repo (rg confirms zero), and this is the only directive matcher, so App Router / Pages Router / dev are all fixed together as described.
  • Tests updated correctly: the two prior tests encoded the old buggy exact-match behavior; the rewritten/added tests assert the new (Next.js-parity) behavior. vp test run tests/csp.test.ts → 15 passed; vp test run tests/nextjs-compat/script-nonce.test.ts → 4 passed (no SSR regression).

Non-blocking observations (pre-existing, out of scope)

The nonce source parsing in getScriptNonceFromHeader still diverges slightly from Next.js — vinext splits on a single space and uses a startsWith("'nonce-") + endsWith("'") guard, whereas Next.js splits on /\s+/ and matches /^'nonce-([A-Za-z0-9+/_-]+={0,2})'$/. The existing whitespace test passes because empty split segments fail the startsWith guard, and the ESCAPE_REGEX guard backstops invalid characters. This is unchanged by this PR and not a regression, so it shouldn't block the merge — just noting it as a potential future parity follow-up (e.g., a nonce containing internal whitespace would be parsed differently, though that's not valid base64 anyway).

Nice, focused fix with clear parity documentation in both the code comment and the PR description.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Summary of my findings:

Review: Approved ✅

PR #2049 fixes the CSP nonce directive matching (#1989). My assessment:

The fix is correct and faithfully matches Next.js. I verified against canary get-script-nonce-from-header.tsx, which uses startsWith('script-src') then startsWith('default-src') — exactly what this PR now does. The old matchesDirectiveName exact-match predicate incorrectly skipped script-src-elem/script-src-attr, which is the root cause of the bug.

Verified:

  • Parity is exact, including single-directive semantics (nonce read only from the first matching script-src* directive).
  • The removed matchesDirectiveName helper had zero remaining references.
  • Tests pass: tests/csp.test.ts (15) and tests/nextjs-compat/script-nonce.test.ts (4, no SSR regression).
  • The rewritten tests correctly replace the two that encoded the old buggy behavior.

Non-blocking observation: The nonce source parsing (single-space split + startsWith/endsWith guard) still diverges slightly from Next.js's /\s+/ split + regex, but that's pre-existing, out of scope, and not a regression. Noted as a possible future parity follow-up.

Clean, well-scoped fix with good documentation.

github run

@james-elicx
james-elicx merged commit 067a7c6 into cloudflare:main Jun 15, 2026
44 checks passed
@Divkix
Divkix deleted the fix/issue-1989 branch June 15, 2026 22:41
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.

CSP: script nonce extraction misses script-src-elem/script-src-attr directives

2 participants