← 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 등급이며, 상한선은 버그 자체가 아니라 해당 페이지가 원래 걸어둔 정책이 결정합니다. 기본 생성된 정책 객체는 모든 질의에 허용으로 응답하므로, worklet realm 안에서 CSP는 사실상 통제 수단으로 동작하지 않았습니다. memory-safety 요소는 없으며, 공격자가 이미 확보하지 못한 발판을 새로 얻게 되는 것도 아닙니다.

Content Security Policy는 document 단위로 기술되지만, 페이지가 스크립트를 실행할 수 있는 곳은 document 하나뿐이 아닙니다. 페이지가 addModule()로 띄우는 worklet은 모두 독립적인 스크립트 실행 컨텍스트입니다. AudioWorklet 뒤의 오디오 렌더링 realm, PaintWorklet 뒤의 paint callback realm이 여기에 해당하며, 각각 자신의 ContentSecurityPolicy 객체를 갖습니다. CSP3는 그 객체가 무엇을 담아야 하는지 명시하고 있습니다. 소유 document가 생성했고 그와 same-origin인 worklet global scope는 생성자의 policy list를 상속해야 하며, 모듈 fetch는 script-src의 통제를 받아야 합니다. 이번 commit 이전 WebKit의 worklet에서는 두 조건 모두 지켜지지 않았습니다.

관전 포인트: 엄격한 정책이 걸린 페이지에서 스크립트 실행 발판을 잡은 뒤, 아무런 강제도 이루어지지 않는 audio worklet으로 자리를 옮기는 흐름을 생각해 볼 수 있습니다. 그 안에서는 eval()이 제약 없이 실행되고, 페이지의 script-src가 금지한 원격 스크립트도 로드되며, 결과는 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) {
+ // 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 {

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:

서로 거의 겹치지 않는 두 개의 파일 묶음에서, 독립적인 enforcement 누락 두 건이 함께 수정되었습니다.

첫 번째 묶음은 policy 상속입니다. applyContentSecurityPolicyResponseHeaders()는 세 줄짜리 wrapper로, ContentSecurityPolicy::didReceiveHeaders()를 호출해 header snapshot을 해당 context의 실제 policy 객체에 반영합니다. 이 함수가 WorkerGlobalScope에서 삭제되고, 공통 base인 WorkerOrWorkletGlobalScope에 동일한 형태로 다시 선언되었습니다. forward declaration 역시 두 헤더 사이를 이동했습니다. WorkletParameters에는 ContentSecurityPolicyResponseHeaders contentSecurityPolicyResponseHeaders 멤버가 추가되었는데, 두 개의 isolatedCopy() overload에 모두 반영되어 worklet thread로 넘어가는 과정에서도 값이 유지됩니다. AudioWorkletMessagingProxy::generateWorkletParameters()가 owner document에서 이 값을 채우고, AudioWorkletGlobalScope::tryCreate()는 addToContextsMap() 직후에 이를 적용합니다. PaintWorkletGlobalScope::tryCreate()에도 같은 호출이 추가되었습니다. 다만 이쪽은 main thread에서 생성되므로 직렬화된 snapshot이 필요 없고, Document에서 바로 읽어옵니다.

두 번째 묶음은 worklet module fetch에 대한 directive 선택이며, process 경계 양쪽 모두에서 수정되었습니다. FetchOptions.h에서는 isScriptLikeDestination() 안에 있던 두 worklet destination을 따로 떼어내어 isWorkletDestination()이라는 이름의 predicate로 분리했습니다. WebContent 쪽 검사인 WorkerModuleScriptLoader::load()는 script-src 분기 조건을 destination == Script에서 Script || isWorkletDestination(...)으로 넓혔습니다. Network 쪽 검사인 NetworkLoadChecker::isAllowedByContentSecurityPolicy()는 redirect가 발생할 때마다 다시 수행되는데, 여기서는 Audioworklet/Paintworklet case가 기존에 fall through로 합류하던 worker 그룹보다 앞에서 script-src 검사 결과를 반환하도록 바뀌었습니다.

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

부수 변경도 있습니다. worklet processor script를 포함한 새 LayoutTest 두 개(audioworklet-inherits-allows-eval, audioworklet-inherits-blocks-eval)가 추가되었습니다. import된 WPT worklets/audio-worklet-csp.https expectation에서는 subtest 세 개가 FAIL → PASS로 바뀌었습니다. 반대로 GLib 플랫폼의 script-src-self/worklet-audio.https.html expectation 두 개는 Pass → Failure로 이동하면서 GTK/WPE port의 후속 작업으로 남았습니다.

Content Security Policy. CSP는 document 단위로 적용되는 policy로, Content-Security-Policy response header나 <meta http-equiv> 태그를 통해 전달됩니다. 어떤 source가 script를 제공할 수 있는지, 그리고 런타임에 문자열을 코드로 바꾸는 동작이 허용되는지를 제한합니다. script 계열 load를 담당하는 directive가 script-src이며, 그 안의 'unsafe-eval' 토큰이 eval()과 new Function()을 허용하는 역할을 맡습니다. worker load는 worker-src가 담당합니다. 각 directive는 명시되지 않았을 때 적용되는 fallback chain을 spec 차원에서 따로 갖습니다. 그래서 둘 중 하나만 선언한 페이지에서도 두 directive를 서로 바꿔 쓸 수 없습니다.

eval 차단 지점. WebCore에서 script를 실행하는 context는 모두 ContentSecurityPolicy 객체를 하나씩 보유합니다. JSC는 문자열을 코드로 평가하기 전에 context를 거쳐 이 policy를 조회하며, policy가 거부한 평가는 script 쪽에 EvalError 예외로 나타납니다.

ContentSecurityPolicyResponseHeaders. policy를 직렬화한 snapshot에 해당합니다. 파싱된 directive 구조가 아니라 raw header 문자열을 담습니다. 전달용 형태라고 볼 수 있는데, ContentSecurityPolicy::didReceiveHeaders()가 snapshot을 실제 policy 객체에 반영하고, 타입 자체가 isolatedCopy()를 제공하므로 다른 thread로 안전하게 전달할 수 있습니다.

Worklet. AudioWorklet과 PaintWorklet은 addModule(url)로 가져온 작성자 제공 ES module을 실행하는 script context입니다. 이 중 audio worklet은 별도의 real-time audio thread에서 동작합니다. 작성자 코드는 AudioWorkletProcessor를 상속한 뒤 registerProcessor()로 이름을 붙여 등록합니다. 페이지에서 AudioWorkletNode(context, name)을 생성하면 해당 processor가 worklet thread에서 인스턴스화되며, 각 processor는 this.port에 MessagePort를 갖고 있어 document 쪽으로 구조화된 메시지를 전달할 수 있습니다.

global scope 계층 구조. 공통 base는 WorkerOrWorkletGlobalScope입니다. 한쪽 갈래에는 WorkerGlobalScope(dedicated, shared, service worker)가 있고, 반대쪽 갈래에는 WorkletGlobalScope가 있으며 그 아래에 AudioWorkletGlobalScope와 PaintWorkletGlobalScope가 위치합니다. 한쪽 subtree에 protected로 선언된 멤버는 반대쪽 subtree에서 접근할 수 없습니다.

WorkletParameters. worklet 생성 시점에 전달되는 main thread 상태를 모아둔 구조체입니다. window URL, runtime flag, sample rate, session ID, settings, referrer policy, agent cluster ID 등이 들어갑니다. 두 개의 isolatedCopy() overload는 brace initialization으로 모든 멤버를 위치 순서에 따라 deep copy합니다. 즉 인자 목록과 멤버 목록이 같은 struct를 설명하는 두 벌의 기술이며, 양쪽 모두 손으로 관리해야 합니다.

Fetch destination과 directive 선택. FetchOptions::Destination은 해당 load가 무엇을 위한 것인지 나타내는 태그로, Script, Worker, Serviceworker, Audioworklet, Paintworklet 등이 있고 종류는 계속 늘어나고 있습니다. CSP는 destination을 directive에 대응시키며, WebKit은 두 계열을 서로 다른 진입점으로 구현했습니다. ContentSecurityPolicy::allowScriptFromSource()는 script-src를, allowWorkerFromSource()는 worker-src를 조회하고, 각자 자신의 fallback 규칙을 적용합니다.

load가 검사되는 지점. worker와 worklet context의 module load는 WebContent process의 WorkerModuleScriptLoader::load()를 거칩니다. 이 함수는 판단을 내리는 동시에 request에 ContentSecurityPolicyEnforcement 값을 기록합니다. 한편 Network process의 NetworkLoadChecker는 같은 종류의 검사를 독립적으로 수행하며, redirect가 발생할 때마다 다시 검증합니다. 이번 commit이 수정한 switch 문의 redirectResponseReceived와 preRedirectURL 인자가 그 재검증에 사용됩니다.

첫 번째 결함은 policy 상속이 이루어지지 않은 문제이며, 그 끝에는 허용적인 기본값이 놓여 있었습니다. 자식 context가 자신을 생성한 쪽 policy의 사본이 아니라 빈 policy를 가진 채 만들어졌기 때문입니다.

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

다이어그램의 두 갈래는 모두 실제 ContentSecurityPolicy 객체로 끝납니다. 양쪽 다 ScriptExecutionContext이고, 그런 context는 예외 없이 policy 객체를 하나 보유하기 때문입니다. 차이는 그 객체에 무엇이 채워졌는가에 있습니다. worker 쪽에서는 parameters struct가 header snapshot을 함께 실어 날랐고, WorkerGlobalScope가 이를 policy에 반영했습니다. 반면 worklet 쪽에는 그것을 실어 나를 수단 자체가 없었습니다. WorkletParameters에 CSP 필드가 존재하지 않았기 때문입니다. 설령 필드가 있었더라도 호출할 함수가 없었습니다. applyContentSecurityPolicyResponseHeaders()가 형제 subtree에 protected로 선언되어 있어, AudioWorkletGlobalScope와 PaintWorkletGlobalScope에서는 보이지 않았기 때문입니다. 결과적으로 worklet realm이 갖게 된 것은 default 생성된 policy였습니다. directive를 하나도 담고 있지 않은 policy는 모든 질의에 허용으로 응답합니다. 문자열이 코드로 바뀌는 것을 막으려고 페이지가 명시한 script-src 통제가, 그 realm 안에서는 아무 역할도 하지 못한 셈입니다.

새로 추가된 audioworklet-inherits-blocks-eval 테스트는 수정된 동작을 정확히 그 지점에 고정합니다. 유발 과정은 그대로 PoC로 쓸 수 있을 만큼 짧습니다:

  1. <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">가 걸린 document를 제공합니다. 'unsafe-eval'이 빠져 있다는 점이 핵심입니다.
  2. new OfflineAudioContext(2, 100, 44100)을 만든 뒤 context.audioWorklet.addModule("…/audioworklet-inherits-blocks-eval.js")를 호출합니다.
  3. 해당 module은 constructor에서 try 블록 안으로 eval("1 + 0")을 호출하는 processor를 등록합니다.
  4. 페이지에서 new AudioWorkletNode(context, 'eval-test')를 호출하면 그 processor가 worklet thread에서 생성됩니다.
  5. processor는 this.port를 통해 결과를 document로 전달합니다.
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 + ".");
        …

영향 범위를 따진다면 5번 단계를 눈여겨볼 필요가 있습니다. worklet은 attacker가 격리되어 갇히는 sandbox가 아닙니다. document로 곧장 이어지는 구조화된 채널을 가진 same-origin realm입니다. 그래서 그곳에서 실행된 코드는, 스스로는 같은 결과를 만들 수 없는 페이지 script에게 그 결과를 전달할 수 있습니다.

두 번째 결함은 동작 방식은 전혀 다르지만 형태는 동일합니다. 누락으로 인해 허용 쪽으로 기울어진 분류입니다. WorkerModuleScriptLoader::load()는 Destination::Script인 경우에만 script-src를 적용하고 나머지는 전부 else로 넘겼습니다. 그 결과 worklet module load는 allowWorkerFromSource()로 검증되고 EnforceWorkerSrcDirective로 표시되었습니다. NetworkLoadChecker::isAllowedByContentSecurityPolicy()도 구조적으로 같은 오답에 도달했습니다. Audioworklet과 Paintworklet case에 본문이 없어 Worker/Serviceworker/Sharedworker 그룹으로 fall through했기 때문입니다. worker-src와 script-src는 fallback chain이 서로 다릅니다. 압도적으로 흔한 경우인 script-src만 선언한 document를 가정해 보겠습니다. 이 경우 worklet load는 작성자가 실제로 명시한 directive가 아니라, worker-src의 fallback이 우연히 해석해 낸 결과에 따라 통제되었습니다.

FAIL → PASS로 바뀐 WPT subtest 세 개는 두 곳의 검사 지점에 정확히 대응합니다. 한편 이미 통과하고 있던 항목도 실패하던 항목만큼이나 많은 것을 알려줍니다:

"A remote-origin worklet should be blocked"는 처음부터 통과하고 있었습니다. 최상위 addModule() fetch가 별도로 검사되는 경로에 있었고, 그 경로는 이미 올바르게 동작하고 있었다는 뜻입니다. 따라서 실제로 도달 가능한 범위는 중첩 import와 redirect 시점의 재검증으로 좁혀집니다. 처음의 올바른 판단이, 그 사이 어긋나 버린 코드에 의해 다시 내려지는 두 지점입니다.

종합하면, 이 vulnerability로 인해 CSP는 origin 내부의 script 실행 경계로서 제 기능을 하지 못하게 되었습니다. 다만 memory corruption primitive를 만들어내지도 않고, sandbox를 넘지도 않습니다. worklet scope와 WorkerModuleScriptLoader는 WebContent process 안에 있습니다. NetworkLoadChecker는 Network process에서 동작하지만, 여기서 벌어진 일은 WebContent process가 시작한 load에 대해 policy 판단을 느슨하게 내린 것뿐입니다. 문제는 이미 제한된 script 실행 발판을 확보한 attacker에게 무엇이 주어지는가입니다. CSP가 억제하려는 상황이 바로 그 지점입니다. 이 결함은 아무것도 강제하지 않는 realm에서의 동적 코드 평가를 허용하고, 여기에 더해 페이지의 script-src가 금지한 origin에서 module script를 가져올 수 있게 합니다. 이번 수정은 CSP3 불변식의 양쪽을 모두 복원합니다. 이제 생성 주체인 document의 policy가 worklet 생성 시점에 전달되며, worklet fetch는 자신을 관장하는 directive를 기준으로 판정됩니다.

worklet global scope가 default 생성된 ContentSecurityPolicy를 가진 채 만들어졌고, 그 결과 eval() 통제를 포함한 worklet 내부의 모든 policy 질의가 허용으로 응답했습니다.

수정의 형태 자체가 이 문제의 성격을 드러냅니다. applyContentSecurityPolicyResponseHeaders()는 애초에 없던 함수가 아닙니다. 존재했고, 정상적으로 동작했으며, WorkerGlobalScope는 이를 올바르게 호출했습니다. 다만 계층 구조에서 잘못된 위치에 선언되어 있었습니다. 공통 base의 한쪽 subtree에 protected로 붙어 있었던 것입니다. 그래서 동일한 동작이 필요했던 형제 subtree는 아무런 신호 없이 그 동작을 전혀 갖지 못했고, 메서드 하나를 WorkerOrWorkletGlobalScope로 끌어올린 것이 구조적 수정의 전부입니다.

나머지 절반은 한 단계 아래에서 같은 주제를 반복합니다. FetchOptions.h의 isScriptLikeDestination()은 이미 두 worklet destination을 script 계열로 분류하고 있었습니다. 올바른 분류가 처음부터 같은 헤더 안에 있었지만, 정작 그것이 필요했던 두 검사 지점은 사용하지 않았습니다. isWorkletDestination()으로의 리팩터링이 단순 정리 이상의 의미를 갖는 이유입니다. 이제 각 지점은 구성원을 다시 나열하다 목록을 틀리는 대신, 범주를 이름으로 지목할 수 있습니다. 보안과 관련된 추상화, 즉 "script를 실행하는 context"를 정의하는 공통 base가 아니라 subclass 쪽에 보안 기능이 붙어 있는 경우를 생각해 볼 수 있습니다. 이후 추가되는 형제 클래스는 모두 그 기능 없이 출발합니다. 게다가 이런 누락에서는 아무런 오류도 발생하지 않습니다. 조용히 허용적인 기본값으로 귀결될 뿐입니다.