CSP strict-dynamic does not block parser-inserted external module scripts without a nonce
WebKit's `strict-dynamic` script gate is two-stage. The external-module path silently skipped stage one — parser-inserted modules ran nonceless.
Source/WebCore/dom/ScriptElement.cpp
Source/WebCore/page/csp/ContentSecurityPolicy.cpp
strict-dynamic changes how CSP evaluates scripts: instead of checking URL allowlists, it trusts scripts that carry a valid nonce and transitively trusts scripts they dynamically create. Parser-inserted scripts (those written in HTML markup, not created via DOM APIs) are explicitly excluded from this transitive trust and must carry their own valid 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 — it assumes the first stage already ran.
This commit fixes a CSP bypass where parser-inserted external module scripts (<script type="module" src="...">) executed without any nonce check under a strict-dynamic policy. requestModuleScript (the external module path) never called stage one, so the fetch-time stage silently passed every parser-inserted module script. The fix adds an allowScriptForStrictDynamic call to ScriptElement::requestModuleScript, mirroring the existing call for classic scripts and inline modules. The function is also renamed from allowNonParserInsertedScripts to allowScriptForStrictDynamic for clarity.
Significance
A page hardened with script-src 'nonce-X' 'strict-dynamic' failed to block parser-inserted external module scripts that lacked any nonce, undermining the entire point of the policy. Any site relying on CSP as a meaningful XSS mitigation boundary was affected.
Audit directions
- Other script-loading paths invoking
allowScriptFromSourceat fetch time but skippingallowScriptForStrictDynamicat request time. Candidates includeimport()dynamic module expressions, workletaddModule(), service workerimportScripts(), and speculative preload paths. The two-stage contract is an implicit invariant: any code reachingallowScriptFromSourceunderstrict-dynamicmust have passedallowScriptForStrictDynamicfirst. That invariant is enforced only by convention, making it fragile across new script-loading code paths.