← All reports

CSP strict-dynamic does not block parser-inserted external module scripts

strict-dynamic checks scripts twice — external modules were only checked once.

Component: WebCore Content Security Policy enforcement | 97937c9

Source/WebCore/dom/ScriptElement.cpp

// requestModuleScript (external module path) — before fix, this call was absent:
+ if (!contentSecurityPolicy->allowScriptForStrictDynamic(*this))
+ return;

Source/WebCore/page/csp/ContentSecurityPolicy.cpp

-bool ContentSecurityPolicy::allowNonParserInsertedScripts(...) const
+bool ContentSecurityPolicy::allowScriptForStrictDynamic(...) const

strict-dynamic changes how CSP evaluates scripts: instead of checking URL allowlists, it trusts scripts carrying a valid nonce and transitively trusts scripts those create dynamically. Parser-inserted scripts — written into the HTML markup rather than created through DOM APIs — are explicitly excluded from that transitive trust and must carry their own nonce. WebKit implements this as a two-stage check: allowScriptForStrictDynamic runs at request time to gate parser-inserted scripts, and allowScriptFromSource runs at fetch time but unconditionally returns true when strict-dynamic is active, assuming stage one already ran. requestModuleScript, the external module path, never called stage one, so the fetch-time stage silently passed every parser-inserted module script. The fix adds the missing call and renames the function from allowNonParserInsertedScripts for clarity.

strict-dynamic two-stage validation:

                       Stage 1                     Stage 2
                  allowScriptForStrictDynamic   allowScriptFromSource
                  (checks parserInserted flag)  (passes if strict-dynamic)

Classic scripts:      CALLED ──────────────────► CALLED   ✓
Inline modules:       CALLED ──────────────────► CALLED   ✓
External modules      MISSING ─────────────────► CALLED   ✗  (BUG: stage 1 skipped)
  (before fix):
External modules      CALLED ──────────────────► CALLED   ✓  (fixed)
  (after fix):

A page hardened with script-src 'nonce-X' 'strict-dynamic' failed to block parser-inserted external module scripts lacking a valid nonce, undermining the entire point of strict-dynamic. Any site relying on CSP as a meaningful XSS mitigation boundary was affected.

This exposes a class of missing CSP gate calls across script-loading paths: look for anything that invokes allowScriptFromSource at fetch time but skips allowScriptForStrictDynamic at request time. Candidates are import() dynamic module expressions, worklet addModule(), service worker importScripts(), and the speculative preload paths. The two-stage contract is an implicit invariant — anything reaching allowScriptFromSource under strict-dynamic must have passed allowScriptForStrictDynamic first — and it is enforced only by convention, not structurally, which makes it fragile every time a new script-loading path is added. In code review, a new request*Script function that reaches a fetch-time CSP call without an adjacent request-time gate is the tell; the asymmetry is visible without cross-file navigation.