Propagate CSP directives from the creating document to WorkletGlobalScope
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
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
Source/WebCore/workers/WorkerOrWorkletGlobalScope.cpp
Source/WebCore/worklets/WorkletParameters.h
Source/WebCore/Modules/webaudio/AudioWorkletMessagingProxy.cpp
Source/WebCore/Modules/webaudio/AudioWorkletGlobalScope.cpp
Source/WebCore/worklets/PaintWorkletGlobalScope.cpp
Source/WebCore/loader/FetchOptions.h
Source/WebCore/bindings/js/WorkerModuleScriptLoader.cpp
Source/WebKit/NetworkProcess/NetworkLoadChecker.cpp
Patch Details
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.
Background
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.
Analysis
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:
- Serve a document under
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">— note the absence of'unsafe-eval'. new OfflineAudioContext(2, 100, 44100), thencontext.audioWorklet.addModule("…/audioworklet-inherits-blocks-eval.js").- The module registers a processor whose constructor calls
eval("1 + 0")inside atry. new AudioWorkletNode(context, 'eval-test')from the page constructs it on the worklet thread.- 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 same-origin worklet importing a remote-origin script" — a nested module import resolved on the worklet thread through
WorkerModuleScriptLoader::load(), previouslyworker-src-checked. - "A remote-origin-redirected worklet" — caught at redirect time in the Network process by
NetworkLoadChecker, previously grouped with the worker destinations. - "A same-origin worklet importing a remote-origin-redirected script" — traverses both.
"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.
Insight
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.
Audit directions
-
Security state a child context must inherit, where the constructor default-initializes instead of copying. Narrow: for each
*GlobalScopecreation path in WebCore —AudioWorkletGlobalScope::tryCreate,PaintWorkletGlobalScope::tryCreate,ServiceWorkerGlobalScope,SharedWorkerGlobalScope— diff the fields ofWorkletParametersagainstWorkerParametersand check that each security-relevant one (COEP/COOP, sandbox flags, referrer policy, Trusted Types enforcement, mixed-content and HTTPS-upgrade state, reporting endpoints) is both carried and applied; the match tell is a member present in one parameters struct and absent from its sibling. Wider: grep every site that constructs a policy container directly —makeUnique<ContentSecurityPolicy>(,setContentSecurityPolicy(,setSecurityOriginPolicy(— and confirm each is followed on all creation paths by an inherit-or-replay call likedidReceiveHeaders(); the shape to notice is a constructor that mints a fresh policy object with no unconditional inheritance call anywhere in the same function. Widest: the invariant is a spawned context inherits its creator's policy, never default-initializes one — carry it into Chromium'sPolicyContainerpropagation to workers and worklets, Node'svmcontext creation, and any capability or sandbox model where a child realm is built with an empty permission set. The question to ask everywhere: what does a freshly-constructed policy object answer when queried, and is that answer permissive? -
Policy decisions keyed on an extensible enum, where new members land in a permissive default or an adjacent fallthrough group. Narrow: enumerate every
switchonFetchOptions::Destinationand everydestination ==comparison that selects a CSP directive —NetworkLoadChecker::isAllowedByContentSecurityPolicy,WorkerModuleScriptLoader::load,ContentSecurityPolicy.cpp,CachedResourceLoader.cpp— and verify each destination in theEnumTraitsForPersistencelist inFetchOptions.h(includingEnvironmentmap,Model,Speculationrules) lands in the directive CSP3 assigns it; the tells are a bodilesscasefalling into a group from a different directive family, and anif (destination == X) … else …whoseelsecarries a specific policy rather than a conservative one. Wider: the same shape appears in any allowlist keyed on a type tag with a catch-all branch — MIME-sniffing tables, Permissions-Policy feature maps,isNonSubresourceRequest-style predicate families — so look for two classification helpers over one enum whose membership sets disagree, exactly asisScriptLikeDestination()already listed the worklet destinations the enforcement sites did not. Widest: classification by omission is a policy decision. It applies to Chromium'snetwork::mojom::RequestDestination-to-directive mapping, to routing tables and firewall rule sets with implicit-allow defaults, and to any language without exhaustive-match enforcement — if adding a member to the enum does not fail to compile, the default branch is the policy and must be the restrictive one. -
Hand-written deep-copy and serialization functions that enumerate members positionally. Narrow: audit
WorkletParameters::isolatedCopy()andWorkerParametersfor member/argument-count parity, then extend to the other cross-thread parameter structs underSource/WebCore/workers/andSource/WebCore/worklets/; the tell is a brace-initializer argument list shorter than the struct's member list — it compiles cleanly and leaves a default-constructed field, which for a policy or credential member means empty or permissive. Wider: the same shape covers every manualisolatedCopy, everycrossThreadCopyspecialization, and every IPC encode/decode pair in WebKit — find structs whose members are touched by more than one hand-maintained copy function and confirm all were updated together; a field added in a diff without a corresponding line in each clone or encode function in the same file is the signal. Widest: any manual member-by-member copy is an unenforced duplicate of the struct definition, and it drifts — true for MojoStructTraits, hand-written serde impls, protobuf conversion helpers, and ORM mappers alike. The remedy question is whether the copy can be made structural (reflection, generated code, designated initializers) so that omission becomes a compile error. -
An enforcement check duplicated across a process boundary, where the copies drift and the weaker one governs. Narrow: for each CSP-relevant destination, compare the WebContent-side decision in
WorkerModuleScriptLoader::loadand theContentSecurityPolicyEnforcementvalue it forwards against the Network-side decision inNetworkLoadChecker::isAllowedByContentSecurityPolicy— this commit had to patch both because they disagreed on the same destination; the tell is a directive chosen in one process with no identically-keyed counterpart in the other. Wider: the split-enforcement shape recurs wherever a check runs pre-flight in the renderer and again at redirect or response time in the network layer — audit mixed-content blocking, CORS preflight caching, and upgrade-insecure-requests for cases where only the initial request is validated, and inspect every function taking aredirectResponseReceived-style flag to confirm the redirect branch enforces at least as much as the initial one. Widest: when one policy is enforced at two points, the effective policy is the weaker of the two — it holds for any client/server validation pair, for API-gateway plus service-level authorization, and for eBPF/LSM hook duplication. The question to carry: if these two implementations disagree, which one does the attacker get to choose?