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 — this is a policy bypass, not memory corruption: a worklet spawned straight from the document skips the header-driven policy setup that workers get for free, and its module fetches were checked against the wrong directive family entirely. Escalation depends on a separate script-injection primitive CSP was supposed to contain.
Content Security Policy is supposed to follow a document's script everywhere that script can run, including the isolated execution environments a page spins up for background processing. WorkerOrWorkletGlobalScope is the shared base class WebCore uses for every one of those environments — dedicated Workers, Shared Workers, Service Workers, and now AudioWorklet and CSS Paint Worklet. Each subclass is expected to populate its own ContentSecurityPolicy object before any script runs inside it, and that invariant quietly didn't hold for Worklets, which are spawned directly from the Document rather than fetched as a standalone script resource carrying its own response headers.
The angle: A page that locks its CSP down against eval() and cross-origin scripts couldn't count on that policy holding inside an AudioWorklet or Paint Worklet — already-injected code could call eval() freely or load a worklet module from an origin script-src was meant to block.
Source/WebCore/Modules/webaudio/AudioWorkletGlobalScope.cpp
Source/WebCore/Modules/webaudio/AudioWorkletMessagingProxy.cpp
Source/WebCore/bindings/js/WorkerModuleScriptLoader.cpp
LayoutTests/security/contentSecurityPolicy/resources/audioworklet-inherits-blocks-eval.js
Patch Details
Base-class promotion. WorkerGlobalScope::applyContentSecurityPolicyResponseHeaders() — previously the only place that turned a fetched response's CSP headers into an enforcing ContentSecurityPolicy — is deleted from WorkerGlobalScope.cpp/.h and re-added, verbatim, as WorkerOrWorkletGlobalScope::applyContentSecurityPolicyResponseHeaders() on the shared base class. This one move is what makes the rest of the fix possible: any subclass, Worker or Worklet, can now call it.
AudioWorklet. WorkletParameters gains a contentSecurityPolicyResponseHeaders field, threaded through both the isolatedCopy() const & and isolatedCopy() && overloads so it survives the copy across the AudioWorklet thread boundary. AudioWorkletMessagingProxy::generateWorkletParameters() populates that field with document->contentSecurityPolicy()->responseHeaders() on the main thread, and AudioWorkletGlobalScope::tryCreate() calls scope->applyContentSecurityPolicyResponseHeaders(parameters.contentSecurityPolicyResponseHeaders) immediately after construction, before any worklet script runs.
PaintWorklet. PaintWorkletGlobalScope::tryCreate() runs synchronously on the main thread, so it skips WorkletParameters entirely and calls applyContentSecurityPolicyResponseHeaders(document.contentSecurityPolicy()->responseHeaders()) directly from the owning Document.
Directive-family dispatch. FetchOptions.h factors the existing Audioworklet/Paintworklet check into a new isWorkletDestination() helper, then builds isScriptLikeDestination() on top of it. WorkerModuleScriptLoader::load() uses that helper to fold worklet module fetches into the same shouldEnforceScriptSrc branch as classic/module scripts, so they're checked with allowScriptFromSource() instead of allowWorkerFromSource(). WebKit::NetworkLoadChecker::isAllowedByContentSecurityPolicy(), which performs the equivalent check in the Networking process for network-level fetches and redirects, gets the same correction: the Audioworklet/Paintworklet case is split out of the shared Worker/ServiceWorker/SharedWorker fallthrough to return allowScriptFromSource() explicitly.
Tests. Two new layout tests, audioworklet-inherits-allows-eval and audioworklet-inherits-blocks-eval, register an AudioWorkletProcessor whose constructor calls eval() and reports whether it was permitted, run once under a script-src that allows 'unsafe-eval' and once under one that doesn't. The imported WPT expectations for audio-worklet-csp.https flip several script-src-self worklet-module cases from FAIL to PASS, confirming the directive-dispatch fix.
Background
Content Security Policy. CSP is a document-level security mechanism delivered via HTTP response header (or <meta> tag) that restricts what a page's script can do — which origins scripts, styles, and other resources may load from (script-src, worker-src, etc.), and whether dynamic code execution primitives like eval() are permitted at all. WebCore represents the parsed set of directives in a ContentSecurityPolicy object; the object holds no directives until didReceiveHeaders() is called with the actual response headers.
Workers and Worklets under WorkerOrWorkletGlobalScope. WebCore's non-window script-execution environments — dedicated Workers, Shared Workers, Service Workers, and the newer AudioWorklet and CSS Paint Worklet — all derive from the shared WorkerOrWorkletGlobalScope base class. Workers are created by fetching a standalone worker script as its own top-level resource, complete with its own HTTP response; Worklets are instead created directly by the owning Document, handing the worklet a block of already-fetched module code with no independent top-level fetch of their own.
WorkletParameters and cross-thread creation. WorkletParameters is a plain data struct WebCore uses to hand configuration from the creating document into a new worklet's global scope constructor — window URL, settings, referrer policy, and so on. AudioWorklet runs on its own dedicated thread, so its WorkletParameters instance is copied across that thread boundary via isolatedCopy(), WebCore's standard pattern for moving a value between threads without sharing reference-counted state; PaintWorklet, by contrast, is constructed synchronously on the main thread straight from the Document.
Fetch destinations and directive families. The CSP fetch-directives model tags every network fetch with a destination — script, worker, audioworklet, paintworklet, and so on — drawn from FetchOptions::Destination. Different destinations are checked against different directive families: classic and module scripts, and worklet module scripts, fall under script-src, while dedicated/shared/service worker scripts fall under worker-src. WebCore exposes this as separate allowScriptFromSource() and allowWorkerFromSource() query methods on ContentSecurityPolicy, one per directive family.
Analysis
The root cause is a missing initialization step on a code path that never existed for the class hierarchy's original member: Worklets inherited an empty, permit-everything ContentSecurityPolicy object, and nothing on their creation path ever populated it.
Before: After:
Document (script-src set) Document (script-src set)
└─ generateWorkletParameters() └─ generateWorkletParameters()
(CSP headers dropped) └─ copies responseHeaders()
└─ tryCreate() → empty policy └─ tryCreate() → applyCSPResponseHeaders()
└─ eval() → allowed (unset policy) └─ eval() → checked against script-src
WorkerOrWorkletGlobalScope's constructor sets up a blank ContentSecurityPolicy, and for Workers, applyContentSecurityPolicyResponseHeaders() closed that gap by feeding in the worker script's own response headers. The problem was that the method lived only on WorkerGlobalScope, one layer below the shared base class — so neither AudioWorkletGlobalScope::tryCreate() nor PaintWorkletGlobalScope::tryCreate() could call it, and in the left column of the diagram above, generateWorkletParameters() never captured a header and tryCreate() never called anything to populate the policy. An unpopulated ContentSecurityPolicy object has no directives to violate, so every allow* query on it — including whatever backs eval() — returns permissive by default, meaning dynamic code execution inside a worklet's process() callback sailed through regardless of the document's script-src. The right column shows the fix: generateWorkletParameters() now captures document->contentSecurityPolicy()->responseHeaders() into WorkletParameters, carried across the AudioWorklet thread boundary via isolatedCopy(), and tryCreate() calls the promoted applyContentSecurityPolicyResponseHeaders() immediately after construction; PaintWorkletGlobalScope, running synchronously on the main thread, skips the isolatedCopy() hop and reads the headers straight off the Document.
The second bug is independent of whether the policy object is populated at all. WorkerModuleScriptLoader::load() decided which directive family to enforce by checking only for FetchOptions::Destination::Script — Audioworklet and Paintworklet fetches fell into the else branch and were checked against allowWorkerFromSource() (worker-src) instead of allowScriptFromSource() (script-src). Per the fetch-directives model in Background, worklet module scripts are supposed to be governed by script-src; because a page can configure worker-src and script-src independently, or omit worker-src and rely on its own fallback resolution, this meant a page's script-src restrictions might not apply to worklet module loads at all. The same misclassification existed a second time in NetworkLoadChecker::isAllowedByContentSecurityPolicy() in the Networking process, which performs the equivalent check for network-level fetches — including redirect targets — and had fallen into the shared Worker/ServiceWorker/SharedWorker case for Audioworklet/Paintworklet before this commit split it out.
Neither half of this bug crosses a process or sandbox boundary — AudioWorklet and PaintWorklet execute inside the WebContent process alongside the owning document, so both gaps defeat a script-execution mitigation rather than process isolation. The bug is reliably reachable from ordinary content via the documented Worklets APIs; an attacker would still need a separate script-injection primitive that CSP was meant to constrain in the first place — say, an existing markup-injection bug the page's script-src was supposed to neutralize — to turn the newly-available eval() or the misdirected worklet module load into anything.
Worklets inherited a blank, permit-all CSP object and no code path ever populated it, so eval() bans and script-src origin restrictions silently didn't apply inside AudioWorklet or Paint Worklet.
Insight
The worklet-vs-worker asymmetry that let the propagation bug slip through — Workers are created by fetching a standalone script resource with its own headers, Worklets are spawned straight from the Document — is also why the fix has to duplicate CSP-header capture per worklet type instead of routing all creation through one path. The second bug compounds that same duplication risk: the fetch-destination-to-directive-family mapping for Worklets is implemented twice, once in WorkerModuleScriptLoader (WebContent process) and once in NetworkLoadChecker (Networking process), and both copies had independently drifted from the spec's script-src classification before this commit corrected them together.
Audit directions
- A security-policy object that defaults to permit-all at construction and needs an explicit, separately-callable step to become restrictive lets any code path that reaches it first silently no-op enforcement — grep WebCore for the other
makeUnique<ContentSecurityPolicy>(...)call sites (ServiceWorkerGlobalScope,SharedWorkerGlobalScope,WorkerGlobalScope, any future Worklet type) and confirm each has an unconditional, reachable-before-first-script-execution call todidReceiveHeaders()/applyContentSecurityPolicyResponseHeaders(). The same construct-then-configure shape recurs for other security-relevant WebKit objects —SecurityOriginPolicycreation ordering, sandbox-flags defaults on new browsing contexts, Cross-Origin-Embedder-Policy/Cross-Origin-Opener-Policy propagation into worker/worklet parameters. Widest aperture: this is the general two-phase-initialization-where-the-security-phase-can-be-skipped class, applicable anywhere a policy/ACL object is built default-permissive and populated later — Chromium'snetwork::mojom::ContentSecurityPolicypropagation to dedicated workers, or a server framework's auth-middleware object constructed with an empty allow-list a separate call is supposed to fill. - A resource-type enum used to select which directive family applies, dispatched independently at more than one enforcement point, drifts silently as new enum values are added — grep WebCore/WebKit for other
fetchOptions.destination ==/switch (m_options.destination)sites routing toallowScriptFromSource/allowWorkerFromSource(WorkerScriptLoader.cpp,ScriptExecutionContext.cpp) and check each against the CSP spec's fetch-directives table forAudioworklet/Paintworklet/Speculationrules. Wider:MixedContentChecker, referrer-policy resolution, and Cross-Origin-Embedder-Policy enforcement all switch on the sameFetchOptions::Destinationenum and are exposed to the same drift. Widest: any system that dispatches security decisions off a resource/action-type tag is vulnerable to this class when new tag values are introduced without auditing every consumer — IAM engines keyed on request-action strings, or Permissions-Policy feature-name lookups, share the same failure mode.