4003087f20 CSP wasm-unsafe-eval directive is not enforced during WebAssembly byte compilation
Triage note: Enforces CSP at WebAssembly.Module byte compilation, closing a policy-bypass hole, a genuine isolation fix.
Contents
The bug at a glance
OBSERVED: adds a globalObject->webAssemblyEnabled() CSP gate to webAssemblyCompileFunc, webAssemblyInstantiateFunc, webAssemblyCompileStreamingFunc, webAssemblyInstantiateStreamingFunc (JSWebAssembly.cpp) and constructJSWebAssemblyModule (WebAssemblyModuleConstructor.cpp), each rejecting with a WebAssembly.CompileError before compilation. INFERRED: previously the CSP wasm-unsafe-eval check ran only at instance creation (JSWebAssemblyInstance::tryCreate), so byte compilation via WebAssembly.compile / new WebAssembly.Module / compileStreaming / instantiateStreaming succeeded despite policy. Medium: a policy-enforcement (CSP) bypass, not direct memory corruption, but it defeats a defense-in-depth control and enables transferring a compiled Module to a same-origin Worker where instantiation is unchecked.
CSP’s wasm-unsafe-eval is meant to gate all WebAssembly compilation, but the enforcement point was misplaced: it lived only in JSWebAssemblyInstance::tryCreate() (the instantiate step). Everything that produces a Module object without instantiating – compile(), the Module constructor, and the streaming variants – ran unchecked. The subtle escalation is that a Module produced under a restrictive policy can be postMessage’d to a same-origin Worker whose instantiation path was the only guarded step, and there it instantiates cleanly, fully bypassing the directive.
Root cause
Content-Security-Policy’s wasm-unsafe-eval source expression governs whether a realm may compile and run WebAssembly. In JSC this policy is surfaced through JSGlobalObject::webAssemblyEnabled(), which consults the embedder’s CSP; when the directive is absent the method returns false and callers are expected to reject with a WebAssembly.CompileError carrying webAssemblyDisabledErrorMessage().
Before this patch the only place that consulted webAssemblyEnabled() was JSWebAssemblyInstance::tryCreate(), the final instantiation step. Every entry point that merely compiles bytes to a Module – webAssemblyCompileFunc (WebAssembly.compile), constructJSWebAssemblyModule (new WebAssembly.Module), webAssemblyCompileStreamingFunc (WebAssembly.compileStreaming), and the instantiate/streaming variants – began parsing and JIT-compiling the module bytes without ever consulting the policy. So under a CSP that omits wasm-unsafe-eval, WebAssembly.compile(bytes) still returned a fully compiled Module. That is already a direct bypass of the directive’s compile-time guarantee.
The escalation OBSERVED in the commit message: a Module compiled in a document under a restrictive policy is a structured-cloneable object and can be transferred via postMessage to a same-origin Worker. Instantiation in the Worker went through the one guarded path, but because the Module was already compiled, and depending on the Worker’s own policy inheritance, the attacker obtains executable Wasm where the top-level policy intended to forbid it.
The fix installs the same [[unlikely]] webAssemblyEnabled() check at the head of each compile/instantiate host function. The promise-returning entry points (compile, instantiate, and the two streaming variants) RELEASE_AND_RETURN a JSPromise::rejectedPromise wrapping createJSWebAssemblyCompileError(…, webAssemblyDisabledErrorMessage()), rejecting before any JSPromise work or fetch begins. The synchronous constructJSWebAssemblyModule throwException()s the same CompileError before createSourceBufferFromValue reads the module bytes. This moves enforcement to every point where Wasm bytes first enter the compiler, matching the guarantee already present at instance creation.
Key code
CSP guard added to each Wasm compile entry point (JSWebAssembly.cpp)
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
+ if (!globalObject->webAssemblyEnabled()) [[unlikely]] {
+ auto error = createJSWebAssemblyCompileError(globalObject, vm, globalObject->webAssemblyDisabledErrorMessage());
+ RELEASE_AND_RETURN(scope, JSValue::encode(JSPromise::rejectedPromise(globalObject, error)));
+ }
+
auto* promise = JSPromise::create(vm, globalObject->promiseStructure());
Patch walkthrough
Source/JavaScriptCore/wasm/js/JSWebAssembly.cpp— Adds an identical [[unlikely]] guard to webAssemblyCompileFunc, webAssemblyInstantiateFunc, webAssemblyCompileStreamingFunc, and webAssemblyInstantiateStreamingFunc. When !globalObject->webAssemblyEnabled(), each builds a CompileError via createJSWebAssemblyCompileError(globalObject, vm, globalObject->webAssemblyDisabledErrorMessage()) and RELEASE_AND_RETURN a JSPromise::rejectedPromise, so the promise rejects before any compilation or fetch work starts.Source/JavaScriptCore/wasm/js/WebAssemblyModuleConstructor.cpp— constructJSWebAssemblyModule (the new WebAssembly.Module path) gains the same guard, but being synchronous it throwException()s the CompileError and returns before createSourceBufferFromValue reads argument(0), so no module bytes are ever handed to the compiler under a disallowing policy.LayoutTests/.../wasm-unsafe-eval/*streaming*.any.js (and WebAssembly-blocked*)— New/updated WPT and internal CSP tests assert that compile, Module(), compileStreaming, and instantiateStreaming are now blocked (reject with CompileError) under default-src / script-src policies lacking wasm-unsafe-eval, and still allowed when the directive is present.
Background
wasm-unsafe-eval — CSP source expression that, along with unsafe-eval, authorizes WebAssembly compilation/execution in a realm. Its absence is meant to block all Wasm compilation, not merely instantiation.
webAssemblyEnabled() — JSGlobalObject predicate that reports whether the current realm’s CSP permits Wasm. It was consulted only in JSWebAssemblyInstance::tryCreate() prior to the patch.
JSWebAssemblyInstance::tryCreate — The instance-creation step, the sole pre-patch CSP enforcement point. Because it runs only at instantiation, precompiled Module objects escaped the check.
Structured clone of Module — WebAssembly.Module is transferable via postMessage/structured clone. A Module compiled in one realm can be handed to a same-origin Worker, decoupling the compile-time policy of the producer from the instantiate-time policy of the consumer.
Vulnerability window
- Design gap — CSP wasm enforcement was implemented only at JSWebAssemblyInstance::tryCreate(), leaving compile-time entry points ungated.
- Bypass — Under a policy lacking wasm-unsafe-eval, WebAssembly.compile / new Module / compileStreaming / instantiateStreaming still produced compiled Modules.
- Escalation — A compiled Module postMessage’d to a same-origin Worker instantiates through the one guarded path, defeating the top-level directive.
- Discovery — bug 315489 / rdar://175340639 documented that byte compilation ignored the directive.
- Fix — webAssemblyEnabled() guard added to all five compile/instantiate host functions, each rejecting with CompileError before any work.
Proof of concept
Illustrative trigger reconstructed from the WPT set (default-src-blocks-wasm-streaming / script-src-blocks-wasm-streaming). The behavioral assertion in the added expected.txt files is that these operations are blocked under a policy lacking wasm-unsafe-eval. The pre-patch bypass additionally chained a successful WebAssembly.compile followed by postMessage of the resulting Module to a same-origin Worker for unchecked instantiation.
// Under a CSP such as: default-src 'self' (no wasm-unsafe-eval)
// Pre-patch these all succeed; post-patch each rejects/throws WebAssembly.CompileError.
const bytes = new Uint8Array([0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00]);
await WebAssembly.compile(bytes); // rejected post-patch
new WebAssembly.Module(bytes); // throws post-patch
await WebAssembly.compileStreaming(fetch('m.wasm')); // rejected post-patch
await WebAssembly.instantiateStreaming(fetch('m.wasm')); // rejected post-patch
Exploitation
- Establish restrictive CSP — Target realm serves a CSP without wasm-unsafe-eval, intending to forbid Wasm.
- Compile anyway — Attacker script calls WebAssembly.compile / new Module / *Streaming, which pre-patch returned a compiled Module despite the policy.
- Relay to Worker — The Module is postMessage’d to a same-origin Worker whose instantiation path was the only guarded step, yielding executing Wasm and nullifying the directive as a JIT-hardening / attack-surface control.
Detection & hunting
For defenders and SOC / detection engineers:
- Successful compile under blocking CSP —
- Missing CompileError on blocked realms —
Audit directions
- All Wasm entry points —
- Policy inheritance across contexts —
- Other capability gates at wrong layer —