← All reports

Propagate CSP directives from the creating document to WorkletGlobalScope

MediumWebCore worklets — AudioWorklet / PaintWorklet global scopes and their CSP enforcement pointsCrossOrigin

CVE: CVE-2026-43670 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may bypass Content Security Policy Apple's description: A Content Security Policy bypass was addressed with improved enforcement in AudioWorklet contexts. Credit: lebr0nli of National Yang Ming Chiao Tung University, Security and Systems Lab

933debd | Bugzilla 309004

Medium, and the ceiling is set by the page's own policy rather than by the bug. A default-constructed policy object answers every query permissively, so inside the worklet realm CSP simply wasn't a control — no memory-safety component, and nothing here hands an attacker a foothold they didn't already have.

Content Security Policy is written per-document, but the document is not the only place a page can run script. Every worklet a page spawns with addModule() — the audio-rendering realm behind AudioWorklet, the paint-callback realm behind PaintWorklet — is a full script-execution context in its own right, with its own ContentSecurityPolicy object attached to it. CSP3 is explicit about what that object should contain: a worklet global scope, created by and same-origin with its owner document, inherits the creator's policy list, and its module fetches are governed by script-src. Neither half of that held for WebKit's worklets before this commit.

The angle: A script foothold on a page with a strict policy could relocate into an audio worklet where nothing was enforced — eval() runs freely, remote scripts the page's script-src forbids load, and results come back to the page over a message port.

Source/WebCore/workers/WorkerGlobalScope.cpp

-void WorkerGlobalScope::applyContentSecurityPolicyResponseHeaders(const ContentSecurityPolicyResponseHeaders& contentSecurityPolicyResponseHeaders)
-{
- protect(contentSecurityPolicy())->didReceiveHeaders(contentSecurityPolicyResponseHeaders, String { });
-}

Source/WebCore/workers/WorkerOrWorkletGlobalScope.cpp

+void WorkerOrWorkletGlobalScope::applyContentSecurityPolicyResponseHeaders(const ContentSecurityPolicyResponseHeaders& contentSecurityPolicyResponseHeaders)
+{
+ protect(contentSecurityPolicy())->didReceiveHeaders(contentSecurityPolicyResponseHeaders, String { });
+}

Source/WebCore/worklets/WorkletParameters.h

std::optional<uint64_t> noiseInjectionHashSalt;
String agentClusterID;
+ ContentSecurityPolicyResponseHeaders contentSecurityPolicyResponseHeaders;
 
- WorkletParameters isolatedCopy() const & { return { ..., agentClusterID.isolatedCopy() }; }
- WorkletParameters isolatedCopy() && { return { ..., WTF::move(agentClusterID).isolatedCopy() }; }
+ WorkletParameters isolatedCopy() const & { return { ..., agentClusterID.isolatedCopy(), contentSecurityPolicyResponseHeaders.isolatedCopy() }; }
+ WorkletParameters isolatedCopy() && { return { ..., WTF::move(agentClusterID).isolatedCopy(), WTF::move(contentSecurityPolicyResponseHeaders).isolatedCopy() }; }

Source/WebCore/Modules/webaudio/AudioWorkletMessagingProxy.cpp

document->advancedPrivacyProtections(),
document->noiseInjectionHashSalt(),
- document->agentClusterID()
+ document->agentClusterID(),
+ protect(document->contentSecurityPolicy())->responseHeaders()
};

Source/WebCore/Modules/webaudio/AudioWorkletGlobalScope.cpp

auto scope = adoptRef(*new AudioWorkletGlobalScope(thread, vm.releaseNonNull(), parameters));
scope->addToContextsMap();
+ scope->applyContentSecurityPolicyResponseHeaders(parameters.contentSecurityPolicyResponseHeaders);
return scope;

Source/WebCore/worklets/PaintWorkletGlobalScope.cpp

auto scope = adoptRef(*new PaintWorkletGlobalScope(document, vm.releaseNonNull(), WTF::move(code)));
scope->addToContextsMap();
+ scope->applyContentSecurityPolicyResponseHeaders(protect(document.contentSecurityPolicy())->responseHeaders());
return scope;

Source/WebCore/loader/FetchOptions.h

-inline bool isScriptLikeDestination(FetchOptions::Destination destination)
+inline bool isWorkletDestination(FetchOptions::Destination destination)
{
return destination == FetchOptions::Destination::Audioworklet
- || destination == FetchOptions::Destination::Paintworklet
+ || destination == FetchOptions::Destination::Paintworklet;
+}
+
+inline bool isScriptLikeDestination(FetchOptions::Destination destination)
+{
+ return isWorkletDestination(destination)
|| destination == FetchOptions::Destination::Script

Source/WebCore/bindings/js/WorkerModuleScriptLoader.cpp

CheckedPtr contentSecurityPolicy = context.contentSecurityPolicy();
- if (fetchOptions.destination == FetchOptions::Destination::Script) {
+ // Worklets and scripts are governed by script-src; workers are governed by worker-src.
+ bool shouldEnforceScriptSrc = fetchOptions.destination == FetchOptions::Destination::Script
+ || isWorkletDestination(fetchOptions.destination);
+ if (shouldEnforceScriptSrc) {
cspCheckFailed = contentSecurityPolicy && !contentSecurityPolicy->allowScriptFromSource(m_sourceURL, WTF::move(sourcePosition));
contentSecurityPolicyEnforcement = ContentSecurityPolicyEnforcement::EnforceScriptSrcDirective;
} else {

Source/WebKit/NetworkProcess/NetworkLoadChecker.cpp

switch (m_options.destination) {
case FetchOptions::Destination::Audioworklet:
case FetchOptions::Destination::Paintworklet:
+ return contentSecurityPolicy->allowScriptFromSource(request.url(), { }, redirectResponseReceived, preRedirectURL);
case FetchOptions::Destination::Worker:
case FetchOptions::Destination::Serviceworker:
case FetchOptions::Destination::Sharedworker:

Two independent enforcement gaps are closed, in two clusters of files that barely touch each other.

The first cluster is policy inheritance. applyContentSecurityPolicyResponseHeaders() — a three-line wrapper that replays a header snapshot into the context's live policy via ContentSecurityPolicy::didReceiveHeaders() — is deleted from WorkerGlobalScope and re-declared verbatim on the shared base WorkerOrWorkletGlobalScope, along with its forward declaration moving between the two headers. WorkletParameters gains a ContentSecurityPolicyResponseHeaders contentSecurityPolicyResponseHeaders member, threaded through both isolatedCopy() overloads so it survives the hop to the worklet thread. AudioWorkletMessagingProxy::generateWorkletParameters() fills it from the owner document; AudioWorkletGlobalScope::tryCreate() applies it immediately after addToContextsMap(). PaintWorkletGlobalScope::tryCreate() gets the same call, reading straight off the Document because it is constructed on the main thread and needs no serialized snapshot.

The second cluster is directive selection for worklet module fetches, and it is patched on both sides of the process boundary. FetchOptions.h factors the two worklet destinations out of isScriptLikeDestination() into a named isWorkletDestination() predicate. WorkerModuleScriptLoader::load() — the WebContent-side check — widens its script-src branch from destination == Script to Script || isWorkletDestination(...). NetworkLoadChecker::isAllowedByContentSecurityPolicy() — the Network-side check, which also runs on every redirect — inserts a script-src return for the Audioworklet/Paintworklet cases ahead of the worker group they previously fell into.

  Before:                              After:
  destination == Script ?              Script || isWorkletDestination() ?
   ├ yes → allowScriptFromSource()      ├ yes → allowScriptFromSource()   [script-src]
   └ no  → allowWorkerFromSource()      └ no  → allowWorkerFromSource()   [worker-src]
        ▲                                    ▲
        └─ Audioworklet, Paintworklet        └─ Worker, Serviceworker, Sharedworker

Collateral: two new LayoutTests (audioworklet-inherits-allows-eval, audioworklet-inherits-blocks-eval) with their worklet processor scripts; three subtests in the imported WPT worklets/audio-worklet-csp.https expectation flip FAIL → PASS; two GLib-platform script-src-self/worklet-audio.https.html expectations move Pass → Failure, marking follow-up work on the GTK/WPE ports.

Content Security Policy. CSP is a per-document policy, delivered by a Content-Security-Policy response header or a <meta http-equiv> tag, that constrains which sources may supply script and whether strings may be turned into code at runtime. script-src is the directive covering script-like loads; the 'unsafe-eval' token inside it is what permits eval() and new Function(). worker-src covers worker loads. Each directive has its own spec-defined fallback chain for when it is absent, so the two are not interchangeable even on a page that declares only one of them.

The eval gate. Every script-running context in WebCore owns a ContentSecurityPolicy object. JSC consults it through the context before evaluating a string as code, and an evaluation the policy refuses surfaces to script as a thrown EvalError.

ContentSecurityPolicyResponseHeaders. This is the serializable snapshot of a policy — the raw header strings, not the parsed directive structures. It is the transport form: ContentSecurityPolicy::didReceiveHeaders() replays a snapshot into a live policy object, and the type carries an isolatedCopy() so it can be handed to another thread safely.

Worklets. AudioWorklet and PaintWorklet are script contexts that run author-supplied ES modules fetched via addModule(url). Audio worklets run on a dedicated real-time audio thread; author code subclasses AudioWorkletProcessor and registers it under a name with registerProcessor(). Constructing an AudioWorkletNode(context, name) from the page instantiates that processor on the worklet thread, and each processor holds a MessagePort at this.port for structured messaging back to the document.

The global-scope hierarchy. WorkerOrWorkletGlobalScope is the shared base. WorkerGlobalScope (dedicated, shared, and service workers) descends from it on one side; WorkletGlobalScope descends on the other, with AudioWorkletGlobalScope and PaintWorkletGlobalScope below that. A protected member declared on one subtree is not reachable from the other.

WorkletParameters. The aggregate of main-thread state handed to a worklet at creation — window URL, runtime flags, sample rate, session ID, settings, referrer policy, agent cluster ID. Its two isolatedCopy() overloads deep-copy every member positionally through brace initialization, which means the argument list and the member list are two hand-maintained descriptions of the same struct.

Fetch destinations and directive selection. FetchOptions::Destination is the tag identifying what a load is for — Script, Worker, Serviceworker, Audioworklet, Paintworklet, and a growing list of others. CSP maps destinations to directives, and WebKit implements the two families through separate entry points: ContentSecurityPolicy::allowScriptFromSource() consults script-src, allowWorkerFromSource() consults worker-src, each applying its own fallback rules.

Where a load gets checked. Module loads for worker-and-worklet contexts run through WorkerModuleScriptLoader::load() in the WebContent process, which both makes the decision and stamps a ContentSecurityPolicyEnforcement value onto the request. NetworkLoadChecker in the Network process performs the same class of check independently, including re-validation after each redirect — the redirectResponseReceived and preRedirectURL arguments in the switch this commit edits.

The first defect is a policy-inheritance failure with a permissive default at the end of it — the child context was built with an empty policy rather than a copy of its creator's.

  Document CSP ─┬─ headers in WorkerParameters
                │      └──────────────► WorkerGlobalScope
                │                         └─ applyCSP…ResponseHeaders()  ✓ policy replayed
                │
                └─ (no field, no call)
                       └──────X───────► WorkletGlobalScope
                                          └─ ContentSecurityPolicy{}     ✗ empty → allows all

Both branches of the diagram terminate in a real ContentSecurityPolicy object, because both are ScriptExecutionContexts and every such context owns one. The difference is what got poured into it. On the worker branch, the parameters struct carried a header snapshot and WorkerGlobalScope replayed it. On the worklet branch there was nothing to carry it — WorkletParameters had no CSP field — and nothing to call even if there had been, because applyContentSecurityPolicyResponseHeaders() was protected on the sibling subtree, invisible to AudioWorkletGlobalScope and PaintWorkletGlobalScope. What the worklet realm ended up with was a default-constructed policy, and a policy holding no directives answers every query in the affirmative. The script-src gate that a page writes precisely to keep strings from becoming code was, inside that realm, not a gate at all.

The new audioworklet-inherits-blocks-eval test pins the corrected behavior to exactly that gate. The trigger is short enough to be a PoC:

  1. Serve a document under <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'"> — note the absence of 'unsafe-eval'.
  2. new OfflineAudioContext(2, 100, 44100), then context.audioWorklet.addModule("…/audioworklet-inherits-blocks-eval.js").
  3. The module registers a processor whose constructor calls eval("1 + 0") inside a try.
  4. new AudioWorkletNode(context, 'eval-test') from the page constructs it on the worklet thread.
  5. The processor reports back to the document over this.port.
class EvalTestProcessor extends AudioWorkletProcessor {
    constructor() {
        super();
        var exception;
        try { eval("1 + 0"); } catch (e) { exception = e; }
        if (!exception)
            this.port.postMessage("FAIL should throw EvalError. But did not throw an exception.");
        else if (exception instanceof EvalError)
            this.port.postMessage("PASS threw exception " + exception + ".");
        …

Step 5 is the part worth dwelling on for anyone thinking about impact: the worklet is not an isolated sandbox the attacker has been exiled to. It is a same-origin realm with a structured channel straight back to the document, so code that runs there can hand its results to page script that could not have produced them itself.

The second defect is unrelated in mechanism and identical in shape — a permissive-by-omission classification. WorkerModuleScriptLoader::load() selected script-src enforcement only for Destination::Script and swept everything else into the else, which meant worklet module loads were validated against allowWorkerFromSource() and tagged EnforceWorkerSrcDirective. NetworkLoadChecker::isAllowedByContentSecurityPolicy() reached the same wrong answer structurally: the Audioworklet and Paintworklet cases carried no bodies and fell through into the Worker/Serviceworker/Sharedworker group. Because worker-src and script-src have different fallback chains, a document that declares only script-src — the overwhelmingly common case — left its worklet loads governed by whatever worker-src's fallback happened to resolve to, not by the directive its author actually wrote.

The three WPT subtests that flip FAIL → PASS map cleanly onto those two enforcement sites, and the ones that were already passing are as informative as the ones that weren't:

"A remote-origin worklet should be blocked" passed all along, which places the top-level addModule() fetch on a separately-checked path that was already correct. The reachable surface was therefore specifically nested imports and redirect-time re-validation — the two places where the initial, correct decision gets re-made by code that had drifted.

Taken together, the vulnerability degraded CSP as a script-execution boundary within an origin. It builds no memory-corruption primitive and crosses no sandbox: the worklet scopes and WorkerModuleScriptLoader live in the WebContent process, and while NetworkLoadChecker runs in the Network process, all it did was relax a policy decision for a load the WebContent process initiated. What it hands an attacker who already holds a constrained script foothold — the precise situation CSP exists to contain — is dynamic code evaluation in a realm that enforces nothing, plus module script from origins the page's script-src disallows. The fix restores both halves of the CSP3 invariant: the creating document's policy now reaches the worklet at construction, and worklet fetches are judged against the directive that governs them.

A worklet global scope was created with a default-constructed ContentSecurityPolicy, so every policy query inside the worklet — the eval() gate included — answered permissively.

The shape of the fix is the tell. applyContentSecurityPolicyResponseHeaders() was never missing: it existed, it worked, and WorkerGlobalScope called it correctly. It was declared on the wrong node of the hierarchy — protected on one subtree of a common base — so the sibling subtree that needed identical behavior silently got none, and hoisting one method to WorkerOrWorkletGlobalScope is the entire structural repair. The second half repeats the theme one level down: isScriptLikeDestination() in FetchOptions.h had already listed both worklet destinations as script-like. The correct classification was sitting in the same header the whole time, unused by the two enforcement sites that needed it, which is why the refactor into isWorkletDestination() matters beyond tidiness — it lets those sites name the category instead of re-enumerating its members and getting the list wrong. When a security capability is attached to a subclass rather than to the shared base that defines the security-relevant abstraction — "a context that runs script" — every sibling added afterward starts life without it, and the omission raises nothing: it just resolves to a permissive default.