← WebKit Silent-Fix Report — 2026-W22

4003087f20  CSP wasm-unsafe-eval directive is not enforced during WebAssembly byte compilation

severity medium class Bypass confidence 0.75 WebCore CSP / WebAssembly exploitable-grade
Roberto Rodriguez Thu May 28 15:58:57 2026 -0700 full: 4003087f20b3fb38523cd7c92c804ba44d9405fc bug report ↗ view on GitHub ↗
Primitive: WebAssembly.Module compiles despite wasm-unsafe-eval absent
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

  1. Design gap — CSP wasm enforcement was implemented only at JSWebAssemblyInstance::tryCreate(), leaving compile-time entry points ungated.
  2. Bypass — Under a policy lacking wasm-unsafe-eval, WebAssembly.compile / new Module / compileStreaming / instantiateStreaming still produced compiled Modules.
  3. Escalation — A compiled Module postMessage’d to a same-origin Worker instantiates through the one guarded path, defeating the top-level directive.
  4. Discovery — bug 315489 / rdar://175340639 documented that byte compilation ignored the directive.
  5. 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

  1. Establish restrictive CSP — Target realm serves a CSP without wasm-unsafe-eval, intending to forbid Wasm.
  2. Compile anyway — Attacker script calls WebAssembly.compile / new Module / *Streaming, which pre-patch returned a compiled Module despite the policy.
  3. 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

Before / after

Loading diff…