Security Guide

MCP server WebAssembly security — typed bytecode injection defense, wasm-bindgen glue code, SharedArrayBuffer Spectre attack surface, WASI capabilities

WebAssembly occupies an unusual position in MCP client security: it is simultaneously a meaningful defense against code injection and a source of novel attack surfaces that developers rarely audit. The bytecode validation guarantee that makes Wasm attractive as an injection-resistant compute layer is real — you cannot smuggle a JavaScript eval() call inside a compiled Wasm module. But the JS glue code that wraps every Wasm module is JavaScript, Wasm linear memory is a SharedArrayBuffer accessible from JS, and the WASI capability model is only as restrictive as the host grants it to be.

WebAssembly bytecode validation as an injection defense

WebAssembly is a typed bytecode format. Before a Wasm module executes, the browser's Wasm engine validates the entire module: every instruction is type-checked, every function call's argument types are verified against the callee's signature, every memory access is bounds-checked at the type level, and the module's structure must conform to the binary encoding specification. A module that fails validation is rejected entirely — it does not execute at all.

The consequence for injection attacks is that Wasm bytecode cannot contain arbitrary JavaScript instructions. There is no Wasm opcode for eval, no opcode for document.createElement, no opcode for fetch. A Wasm module can only call JavaScript functions that were explicitly passed to it through the module's import object at instantiation time. This makes Wasm a meaningful defense for computationally intensive MCP tool-processing code — a parser, a cryptographic function, or a sandboxed execution engine — where you want to ensure that injected content cannot introduce new JS execution.

MCP server implementations that compile tool processing logic to Wasm inherit this property. If a malicious tool response tries to inject code into a Wasm-based parser, the injected bytes will be parsed as data by the Wasm module — not as instructions. The Wasm module can only produce output within its defined type signatures. This is a genuine and useful security property, and it is worth deploying Wasm for tool-processing code precisely because of it.

Wasm bytecode validation is a real injection defense. A Wasm module cannot contain eval(), innerHTML assignment, or any other JavaScript execution instruction. The attack surface is not the Wasm bytecode — it is the JavaScript that surrounds it: the import object, the glue code, and the memory buffer exposed to JS.

wasm-bindgen JS glue code as the primary attack surface

Every Wasm module that interacts with browser APIs does so through JavaScript function calls. The Wasm module's import section declares which JS functions it needs, and those functions are provided via the import object passed to WebAssembly.instantiate() or WebAssembly.instantiateStreaming(). The wasm-bindgen tool (used for Rust Wasm modules) and Emscripten (for C/C++) generate JavaScript wrapper code that populates this import object automatically.

This generated glue code is JavaScript. It is not validated by the Wasm engine. It runs in the same JS execution context as the rest of the page. And critically, the import object it creates is populated at instantiation time from the JS environment — which means that any attacker who can influence the JS environment before Wasm instantiation can redirect the functions that the Wasm module calls.

// INSECURE: import object populated from an environment that tool output can influence

// Suppose the MCP client dynamically configures Wasm behavior based on tool metadata:
async function loadWasmToolProcessor(toolConfig) {
  // VULNERABLE: toolConfig comes from a tool response and can be attacker-controlled
  // An attacker response sets toolConfig.logFunction to a string like 'eval'
  // that is then looked up on the global object
  const logFn = window[toolConfig.logFunction] || console.log;

  const importObject = {
    env: {
      log: logFn,               // Wasm calls env.log() — attacker redirected this to eval
      abort: () => { throw new Error('abort'); },
      memory: new WebAssembly.Memory({ initial: 1 })
    }
  };

  const { instance } = await WebAssembly.instantiateStreaming(
    fetch('/wasm/tool-processor.wasm'),
    importObject
  );

  return instance;
}

// SECURE: import object is fully hardcoded — no dynamic function lookup

async function loadWasmToolProcessorSafe() {
  // All imported functions are defined inline — attacker cannot influence them
  const importObject = {
    env: {
      // Safe logging function — cannot be redirected by tool output
      log: (ptr, len) => {
        const bytes = new Uint8Array(memory.buffer, ptr, len);
        console.log(new TextDecoder().decode(bytes));  // textContent-safe output only
      },
      abort: (msg, file, line, col) => {
        throw new Error(`Wasm abort at ${file}:${line}:${col}`);
      },
      memory: new WebAssembly.Memory({ initial: 16, maximum: 256 })
    }
  };

  // Store memory reference for use in log function above
  const memory = importObject.env.memory;

  const { instance } = await WebAssembly.instantiateStreaming(
    fetch('/wasm/tool-processor.wasm'),
    importObject
  );

  return instance;
}

// Audit checklist for Wasm import objects:
// 1. Are all imported function values defined as inline function literals?
// 2. Does any imported function accept and execute a string as code (eval-like)?
// 3. Can tool output or user input influence which functions are passed as imports?
// 4. Does any imported function write tool output to innerHTML or similar sinks?

The security review of a Wasm deployment must focus on the import object as intently as it focuses on the Wasm bytecode itself. A well-validated Wasm module paired with a poorly constructed import object is vulnerable to the same injection attacks as pure JavaScript. The Wasm engine validates bytecode but does not validate the behavior of the JS functions that bytecode calls.

Wasm linear memory as SharedArrayBuffer — the Spectre attack surface

WebAssembly linear memory is exposed to JavaScript as a WebAssembly.Memory object. The underlying buffer of a WebAssembly.Memory is a SharedArrayBuffer — specifically, when the shared: true option is used for multithreaded Wasm. Even for non-shared Wasm memory, the memory.buffer is an ArrayBuffer that JS code can view via typed arrays, reading any byte of Wasm memory directly.

This creates two security issues. First, JS code running in the same page can read sensitive data that the Wasm module wrote to its linear memory — including decrypted content, intermediate cryptographic state, or parsed tool output that was meant to stay inside the Wasm sandbox. Second, when the Wasm memory is genuinely a SharedArrayBuffer (required for Wasm threads via SharedArrayBuffer + Atomics), Spectre-class timing attacks become possible if the page is not cross-origin isolated.

Spectre exploits the speculative execution behavior of modern CPUs to read memory across process boundaries using timing side channels. The key enabler is a high-resolution timer. SharedArrayBuffer used with Atomics.wait() provides an effectively arbitrary-resolution timer — by incrementing a counter in a shared buffer from a worker thread, the main thread can measure time at nanosecond resolution. This is why browsers disabled SharedArrayBuffer after Spectre was disclosed in 2018 and only re-enabled it once the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers were standardized as a mandatory prerequisite.

// Check for cross-origin isolation before using any SharedArrayBuffer or Wasm threads
// This check must run before any Wasm module that uses shared memory is loaded

function assertCrossOriginIsolated() {
  if (!crossOriginIsolated) {
    throw new Error(
      'This page requires cross-origin isolation (COOP + COEP headers) ' +
      'before loading Wasm modules that use SharedArrayBuffer. ' +
      'Without crossOriginIsolated, SharedArrayBuffer enables Spectre timing attacks.'
    );
  }
}

// Required HTTP response headers for cross-origin isolation:
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Embedder-Policy: require-corp
//
// These headers restrict how the page can be embedded and what resources it can load,
// but they are the only mechanism that makes SharedArrayBuffer safe to use.

// Example: Express middleware to set COOP + COEP for Wasm-heavy MCP client pages
app.use('/wasm-tools', (req, res, next) => {
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
  next();
});

// Wasm memory is still readable from JS even without SharedArrayBuffer threading
// Sensitive data in Wasm memory should be zeroed after use:
function zeroWasmMemory(memory, offset, length) {
  const view = new Uint8Array(memory.buffer, offset, length);
  view.fill(0);   // Overwrite sensitive bytes — prevents JS reads of stale data
}

WASI capability-based security model

WebAssembly System Interface (WASI) is a standardized API surface for Wasm modules that need to interact with the operating system — reading files, connecting to network sockets, reading environment variables, and so on. WASI is designed around a capability-based security model: Wasm modules receive handles to specific resources rather than ambient access to the entire OS. A Wasm module cannot open an arbitrary file path; it can only access paths within directory handles that the host explicitly grants to it.

This model is an excellent fit for MCP tools deployed as Wasm modules. A tool that parses JSON should receive no filesystem capabilities at all. A tool that reads configuration files should receive a directory handle scoped to the configuration directory, not to the entire filesystem. The WASI --dir flag (in runtimes like Wasmtime and WAMR) grants access to a specific directory path; --mapdir maps a host path to a guest-visible path, allowing the Wasm module to access /config when the actual host path is /var/app/mcp-config — preventing path traversal from revealing real host directory structure.

// WASI capability grants — Wasmtime CLI examples

// OVERLY PERMISSIVE: grants access to the entire filesystem
// wasmtime --dir=/ tool-processor.wasm
// A compromised or vulnerable Wasm module can read /etc/passwd, /proc, etc.

// MINIMUM NECESSARY: grant only the specific directories the module needs
// wasmtime \
//   --dir=/var/app/mcp-config::config \    # maps host path to guest /config only
//   --dir=/tmp/tool-scratch::scratch \     # isolated scratch directory
//   --env APP_ENV=production \             # explicit env var whitelist
//   tool-processor.wasm

// In JavaScript (browser WASI via @bjorn3/browser_wasi_shim or similar):
import { WASI, OpenFile, File, ConsoleStdout } from '@bjorn3/browser_wasi_shim';

// SECURE: only the specific files the module needs, nothing else
const args = ['tool-processor'];
const env = ['APP_ENV=production'];   // No other env vars granted

// Provide only the minimum filesystem entries
const fds = [
  new OpenFile(new File([])),    // stdin
  ConsoleStdout.lineBuffered((msg) => console.log('[wasm]', msg)),  // stdout
  ConsoleStdout.lineBuffered((msg) => console.warn('[wasm]', msg)), // stderr
  // Do NOT add filesystem directories unless the module explicitly requires them
];

const wasi = new WASI(args, env, fds);

const { instance } = await WebAssembly.instantiateStreaming(
  fetch('/wasm/tool-processor.wasm'),
  { wasi_snapshot_preview1: wasi.wasiImport }
);

wasi.start(instance);

// AUDIT QUESTION: does your Wasm module's WASI instantiation include any of these?
// --dir=/  or  RootDir  or  PreopenDirectory('/')   ← overly broad
// --env=*  or  passing process.env directly          ← leaks host secrets

Emscripten FS API and heap data exposure

Emscripten, the C/C++-to-Wasm compiler toolchain, provides a virtual filesystem API (FS.readFile(), FS.writeFile()) that abstracts file I/O within the Wasm module. When Emscripten output is built with file access enabled and the virtual filesystem is mapped to real host paths, the Emscripten FS API provides a path from the Wasm module to the host filesystem that bypasses the WASI capability model.

Additionally, Emscripten allocates string data in the Wasm heap and does not zero it after use by default. Sensitive tool output processed by Emscripten Wasm code — decrypted data, parsed credentials, session tokens passed to the Wasm module for processing — remains in the Wasm linear memory buffer, readable from JavaScript via a typed array view on instance.exports.memory.buffer, until that memory region is overwritten by subsequent allocations. For MCP clients that process sensitive tool output inside Emscripten-compiled Wasm code, this constitutes a data retention risk in JS-accessible memory.

Attack surface Wasm-specific or general? Mitigation
Injected code via Wasm bytecode Not possible — bytecode is validated Wasm validation is the defense; no additional action needed
Import object function redirection Wasm-specific Hardcode all import functions; never look up by name from tool output
Wasm memory readable from JS Wasm-specific Zero sensitive memory regions after use; apply crossOriginIsolated for threads
SharedArrayBuffer Spectre timing Wasm-specific (threads) Require COOP + COEP; assert crossOriginIsolated before loading threaded Wasm
WASI over-permissive filesystem grant WASI-specific Grant minimum necessary directories; use --mapdir to mask host paths
Emscripten heap data retention Emscripten-specific Explicitly zero Wasm heap regions containing sensitive data after processing

SkillAudit findings for WebAssembly misuse

Critical Wasm import object accepts function references from tool output or user input. The import object passed to WebAssembly.instantiate() includes functions looked up dynamically (e.g., window[toolConfig.callbackName]) rather than defined inline. An attacker who controls tool metadata can redirect Wasm function calls to attacker-controlled JavaScript — including eval, Function, or innerHTML-writing functions. Grade impact: −22.
High Wasm memory (SharedArrayBuffer) used without crossOriginIsolated check. The application loads Wasm modules that use shared memory or Wasm threads without first verifying crossOriginIsolated === true. Without COOP + COEP headers, SharedArrayBuffer combined with Atomics provides a high-resolution timer enabling Spectre-class timing attacks. Grade impact: −20.
High WASI module granted blanket filesystem access instead of minimum required paths. The WASI instantiation grants the Wasm module access to the root directory (/) or a similarly broad path. A vulnerability or logic error in the Wasm module can be leveraged to read arbitrary host files, defeating the WASI capability model's principal security guarantee. Grade impact: −18.
Medium Sensitive data left in Wasm linear memory after processing. Decrypted tool output, session tokens, or credentials passed to a Wasm module for processing remain in the Wasm heap after the operation completes. JavaScript code on the same page can read this data via a typed array view on memory.buffer without any special privileges. Grade impact: −14.
Medium Emscripten FS API mapped to host filesystem without explicit path restrictions. An Emscripten-compiled Wasm module's virtual filesystem is configured to map to real host paths without --mapdir path masking. The guest module can construct paths that traverse to sensitive host directories. Combined with any path-handling vulnerability in the Wasm code, this enables host filesystem reads. Grade impact: −10.

Audit your MCP server for these issues

SkillAudit checks for WebAssembly security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →