← All reports

Propagate CSP directives from the creating document to WorkletGlobalScope

MediumWebCore CSP enforcement (Web Worklets)CrossOrigin

CVE: CVE-2026-43670 · Safari 26.5 · 2026년 5월 13일 출시 Impact: 조작된 웹 콘텐츠를 처리할 경우 Content Security Policy가 우회될 수 있습니다 Apple's description: AudioWorklet 컨텍스트에서의 enforcement를 개선하여 Content Security Policy bypass 문제를 해결했습니다. Credit: National Yang Ming Chiao Tung University Security and Systems Lab의 lebr0nli

933debd | Bugzilla 309004

Medium 등급입니다. Memory corruption이 아니라 policy bypass에 해당하기 때문입니다. Document에서 곧바로 생성된 worklet은 worker가 기본으로 받는 header 기반 policy 설정 과정을 거치지 않습니다. 게다가 worklet의 module fetch는 애초에 잘못된 directive family를 기준으로 검사되었습니다. Escalation 여부는 CSP가 원래 막았어야 할 별도의 script-injection primitive에 달려 있습니다.

Content Security Policy는 document의 script가 실행되는 모든 곳에 적용되어야 합니다. 페이지가 백그라운드 작업을 위해 띄우는 격리된 실행 환경도 예외가 아닙니다. WorkerOrWorkletGlobalScope는 WebCore가 이런 환경 전반에서 사용하는 공통 base 클래스로, dedicated Worker, Shared Worker, Service Worker에 더해 이제는 AudioWorklet과 CSS Paint Worklet까지 포함합니다. 각 서브클래스는 자신 안에서 script가 실행되기 전에 고유한 ContentSecurityPolicy 객체를 채워 넣어야 합니다. 하지만 Worklet에서는 이 불변조건이 조용히 깨져 있었습니다. Worklet은 자체 response header를 가진 독립적인 script 리소스로 fetch되는 대신 Document에서 곧바로 생성되기 때문입니다.

관전 포인트: eval()과 cross-origin script를 막도록 CSP를 엄격하게 설정한 페이지라 해도, 그 정책이 AudioWorklet이나 Paint Worklet 안에서까지 유지된다고 보장할 수 없었습니다. 이미 주입된 코드는 eval()을 자유롭게 호출하거나, script-src가 막았어야 할 origin에서 worklet module을 불러올 수 있었습니다.

Source/WebCore/Modules/webaudio/AudioWorkletGlobalScope.cpp

RefPtr<AudioWorkletGlobalScope> AudioWorkletGlobalScope::tryCreate(...)
{
auto scope = adoptRef(*new AudioWorkletGlobalScope(thread, vm.releaseNonNull(), parameters));
scope->addToContextsMap();
+ scope->applyContentSecurityPolicyResponseHeaders(parameters.contentSecurityPolicyResponseHeaders);
return scope;
}

Source/WebCore/Modules/webaudio/AudioWorkletMessagingProxy.cpp

static WorkletParameters generateWorkletParameters(AudioWorklet& worklet)
{
...
return {
document->url(), jsRuntimeFlags, ...,
 
- document->agentClusterID()
+ document->agentClusterID(),
+ protect(document->contentSecurityPolicy())->responseHeaders()
};
}

Source/WebCore/bindings/js/WorkerModuleScriptLoader.cpp

CheckedPtr contentSecurityPolicy = context.contentSecurityPolicy();
-if (fetchOptions.destination == FetchOptions::Destination::Script) {
+// Worklet과 script는 script-src로 통제되며, worker는 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 {
cspCheckFailed = contentSecurityPolicy && !contentSecurityPolicy->allowWorkerFromSource(m_sourceURL, WTF::move(sourcePosition));
...

LayoutTests/security/contentSecurityPolicy/resources/audioworklet-inherits-blocks-eval.js

+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 + ".");
+ }
+}
+registerProcessor('eval-test', EvalTestProcessor);

Base 클래스로의 승격. WorkerGlobalScope::applyContentSecurityPolicyResponseHeaders()는 fetch된 response의 CSP header를 enforcing ContentSecurityPolicy로 변환하는 유일한 지점이었습니다. 이 함수가 WorkerGlobalScope.cpp/.h에서 제거되고, WorkerOrWorkletGlobalScope::applyContentSecurityPolicyResponseHeaders()라는 이름으로 공통 base 클래스에 그대로 다시 추가되었습니다. 이 한 번의 이동이 나머지 fix를 가능하게 만든 핵심입니다. 이제 Worker든 Worklet이든 어떤 서브클래스에서도 이 함수를 호출할 수 있게 됩니다.

AudioWorklet. WorkletParameterscontentSecurityPolicyResponseHeaders 필드가 추가되었습니다. 이 필드는 isolatedCopy() const &isolatedCopy() && 두 overload 모두를 통과하도록 만들어져, AudioWorklet의 thread 경계를 넘는 복사 과정에서도 값이 유지됩니다. AudioWorkletMessagingProxy::generateWorkletParameters()는 main thread에서 document->contentSecurityPolicy()->responseHeaders()로 이 필드를 채우고, AudioWorkletGlobalScope::tryCreate()는 생성 직후, 즉 worklet script가 실행되기 전에 scope->applyContentSecurityPolicyResponseHeaders(parameters.contentSecurityPolicyResponseHeaders)를 호출합니다.

PaintWorklet. PaintWorkletGlobalScope::tryCreate()는 main thread에서 동기적으로 실행되기 때문에 WorkletParameters를 아예 거치지 않습니다. 대신 소유 Document로부터 곧바로 applyContentSecurityPolicyResponseHeaders(document.contentSecurityPolicy()->responseHeaders())를 호출합니다.

Directive family 분기. FetchOptions.h에서는 기존의 Audioworklet/Paintworklet 검사를 새로운 isWorkletDestination() helper로 분리하고, 그 위에 isScriptLikeDestination()을 구성합니다. WorkerModuleScriptLoader::load()는 이 helper를 이용해 worklet module fetch를 classic/module script와 동일한 shouldEnforceScriptSrc 분기로 합류시킵니다. 그 결과 worklet module fetch는 allowWorkerFromSource()가 아니라 allowScriptFromSource()로 검사됩니다. Networking process에서 network 수준의 fetch와 redirect에 대해 동일한 검사를 수행하는 WebKit::NetworkLoadChecker::isAllowedByContentSecurityPolicy()에도 같은 교정이 적용되었습니다. Audioworklet/Paintworklet 케이스가 기존 Worker/ServiceWorker/SharedWorker의 공통 fallthrough에서 분리되어, allowScriptFromSource()를 명시적으로 반환하도록 바뀌었습니다.

테스트. 새로 추가된 audioworklet-inherits-allows-evalaudioworklet-inherits-blocks-eval 두 layout test는 constructor에서 eval()을 호출하고 허용 여부를 보고하는 AudioWorkletProcessor를 등록합니다. 이 test는 'unsafe-eval'을 허용하는 script-src와 허용하지 않는 script-src 양쪽에서 각각 한 번씩 실행됩니다. Import된 audio-worklet-csp.https의 WPT expectation 역시 script-src-self worklet-module 관련 여러 케이스를 FAIL에서 PASS로 바꾸어, directive dispatch fix가 정상 동작함을 확인합니다.

Content Security Policy. CSP는 HTTP response header(또는 <meta> 태그)로 전달되는 document 수준의 보안 메커니즘으로, 페이지의 script가 할 수 있는 일을 제한합니다. Script, style을 비롯한 리소스가 어떤 origin에서 로드될 수 있는지(script-src, worker-src 등)를 정하고, eval() 같은 동적 코드 실행 primitive의 허용 여부도 결정합니다. WebCore는 파싱된 directive 집합을 ContentSecurityPolicy 객체로 표현합니다. 이 객체는 실제 response header를 담아 didReceiveHeaders()가 호출되기 전까지는 어떤 directive도 갖지 않습니다.

WorkerOrWorkletGlobalScope 하위의 Worker와 Worklet. WebCore에서 window가 아닌 script 실행 환경들 — dedicated Worker, Shared Worker, Service Worker, 그리고 비교적 최근에 추가된 AudioWorklet과 CSS Paint Worklet — 은 모두 공통 base 클래스인 WorkerOrWorkletGlobalScope에서 파생됩니다. Worker는 자체 HTTP response를 갖춘 독립적인 top-level 리소스로서 worker script를 fetch하여 생성됩니다. 반면 Worklet은 소유 Document가 직접 생성하며, 이미 fetch된 module code 덩어리를 넘겨받을 뿐 자신만의 독립적인 top-level fetch는 갖지 않습니다.

WorkletParameters와 cross-thread 생성. WorkletParameters는 생성 주체인 document의 설정값 — window URL, settings, referrer policy 등 — 을 새 worklet의 global scope constructor로 전달하기 위한 단순한 data struct입니다. AudioWorklet은 자신만의 dedicated thread에서 실행되기 때문에, 그 WorkletParameters 인스턴스는 isolatedCopy()를 거쳐 thread 경계를 넘어 복사됩니다. isolatedCopy()는 reference-counted state를 공유하지 않고 값을 thread 간에 옮기는 WebCore의 표준 패턴입니다. 반면 PaintWorklet은 Document로부터 main thread에서 동기적으로 곧바로 생성됩니다.

Fetch destination과 directive family. CSP의 fetch-directives 모델은 모든 network fetch에 FetchOptions::Destination에서 가져온 destination(script, worker, audioworklet, paintworklet 등)을 태그로 붙입니다. Destination에 따라 검사되는 directive family도 달라집니다. Classic script와 module script, worklet module script는 script-src 아래에 속하고, dedicated/shared/service worker script는 worker-src 아래에 속합니다. WebCore는 이를 ContentSecurityPolicyallowScriptFromSource()allowWorkerFromSource()라는 별도의 query method로 노출하며, 각각 하나의 directive family에 대응합니다.

Root cause는 초기화 단계 하나가 빠진 것입니다. 이 class 계층의 원래 멤버에게는 애초에 존재하지 않았던 code path이기도 합니다. Worklet은 비어 있는, 즉 모든 것을 permit하는 ContentSecurityPolicy 객체를 그대로 물려받았고, 생성 경로 어디에도 이 객체를 채워 넣는 코드가 없었습니다.

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의 constructor는 빈 ContentSecurityPolicy를 설정합니다. Worker의 경우 applyContentSecurityPolicyResponseHeaders()가 worker script 자신의 response header를 채워 넣어 이 공백을 메꿔주었습니다. 문제는 이 메서드가 공통 base 클래스보다 한 단계 아래인 WorkerGlobalScope에만 존재했다는 점입니다. 그래서 AudioWorkletGlobalScope::tryCreate()PaintWorkletGlobalScope::tryCreate() 어느 쪽도 이 메서드를 호출할 수 없었습니다. 위 다이어그램의 왼쪽 열이 이 상태를 보여줍니다. generateWorkletParameters()는 header를 전혀 담지 않았고, tryCreate()도 policy를 채우기 위한 어떤 호출도 하지 않았습니다. 채워지지 않은 ContentSecurityPolicy 객체에는 위반할 directive 자체가 없습니다. 그 결과 eval()을 뒷받침하는 것을 포함한 모든 allow* query가 기본적으로 permissive를 반환합니다. 즉 worklet의 process() callback 안에서 이루어지는 동적 코드 실행이 document의 script-src와 무관하게 그대로 통과되었다는 의미입니다. 오른쪽 열은 fix 이후 상태입니다. generateWorkletParameters()는 이제 document->contentSecurityPolicy()->responseHeaders()WorkletParameters에 담아 isolatedCopy()를 거쳐 AudioWorklet의 thread 경계를 넘기고, tryCreate()는 생성 직후 승격된 applyContentSecurityPolicyResponseHeaders()를 호출합니다. Main thread에서 동기적으로 실행되는 PaintWorkletGlobalScopeisolatedCopy() 단계를 거치지 않고 Document에서 header를 곧바로 읽어옵니다.

두 번째 버그는 policy 객체가 채워져 있는지 여부와 무관하게 존재합니다. WorkerModuleScriptLoader::load()는 오직 FetchOptions::Destination::Script인지만 확인해 어느 directive family를 적용할지 결정했습니다. Audioworklet과 Paintworklet의 fetch는 else 분기로 떨어져, allowScriptFromSource()(script-src) 대신 allowWorkerFromSource()(worker-src)로 검사되었습니다. Background에서 설명한 fetch-directives 모델에 따르면 worklet module script는 script-src의 통제를 받아야 합니다. 그런데 페이지는 worker-srcscript-src를 독립적으로 설정할 수도 있고, worker-src를 생략하고 자체 fallback 결정에 맡길 수도 있습니다. 이 때문에 페이지의 script-src 제한이 worklet module load에는 전혀 적용되지 않을 가능성이 있었습니다. 같은 오분류가 Networking process의 NetworkLoadChecker::isAllowedByContentSecurityPolicy()에도 한 번 더 존재했습니다. 이 함수는 redirect 대상을 포함한 network 수준 fetch에 대해 동등한 검사를 수행하는데, 이번 commit이 분리하기 전까지는 Audioworklet/Paintworklet 케이스가 Worker/ServiceWorker/SharedWorker의 공통 분기로 함께 떨어지고 있었습니다.

이 버그의 두 절반 모두 process나 sandbox 경계를 넘지는 않습니다. AudioWorklet과 PaintWorklet은 소유 document와 함께 같은 WebContent process 안에서 실행되므로, 두 gap 모두 process isolation이 아니라 script-execution mitigation을 무력화하는 성격입니다. 문서화된 Worklets API를 통해 일반적인 콘텐츠에서도 이 버그를 안정적으로 유발할 수 있습니다. 다만 새로 열린 eval()이나 잘못 라우팅된 worklet module load를 실제로 활용하려면, 애초에 CSP가 막았어야 할 별도의 script-injection primitive가 필요합니다. 예컨대 페이지의 script-src가 무력화했어야 할 기존의 markup-injection 버그 같은 것입니다.

Worklet은 비어 있는, 모든 것을 permit하는 CSP 객체를 그대로 물려받았고 이를 채워 넣는 code path가 전혀 없었습니다. 그 결과 eval() 금지와 script-src origin 제한이 AudioWorklet과 Paint Worklet 안에서는 조용히 적용되지 않았습니다.

Propagation 버그가 빠져나갈 수 있었던 배경에는 worklet과 worker 사이의 비대칭 구조가 있습니다. Worker는 자체 header를 가진 독립적인 script 리소스를 fetch해서 생성되는 반면, Worklet은 Document에서 곧바로 생성됩니다. 이 비대칭 때문에 fix 역시 생성 경로를 하나로 통합하는 대신, worklet 종류마다 CSP header capture를 중복 구현할 수밖에 없었습니다. 두 번째 버그는 이 중복 위험을 그대로 반복합니다. Worklet에 대한 fetch-destination과 directive-family의 매핑이 WorkerModuleScriptLoader(WebContent process)와 NetworkLoadChecker(Networking process) 두 곳에 각각 구현되어 있었고, 이번 commit이 함께 바로잡기 전까지 두 구현은 spec의 script-src 분류 기준에서 서로 독립적으로 어긋나 있었습니다.