Sandboxing Untrusted Code with Wasm

This guide answers one task: execute a WebAssembly module supplied by someone else — a customer, a marketplace, a third-party integration — with bounded memory, bounded time, and no access to anything you did not explicitly hand it.

Prerequisites

  • [ ] A module you did not compile, in .wasm form.
  • [ ] A worker (browser) or a runtime with resource limits such as Wasmtime (server).
  • [ ] wasm-objdump for inspecting imports before execution.
  • [ ] A written list of the capabilities the module is supposed to need.

Start from the import object

A WebAssembly instance has no ambient authority. It cannot open a socket, read a file, touch the DOM or reach your JavaScript objects, because none of those exist in its world. Everything it can do outside its own memory arrives through imports, which makes the import object the complete specification of what the module is permitted to do.

const memory = new WebAssembly.Memory({ initial: 16, maximum: 256 });   // 1 MiB → 16 MiB, hard cap

const imports = {
  env: { memory },
  host: {
    log:       (ptr, len) => sink.push(readUtf8(memory, ptr, len).slice(0, 4096)),
    now_ms:    () => Math.floor(performance.now()),
    input_len: () => inputBytes.length,
    read_input: (ptr, cap) => writeBytes(memory, ptr, Math.min(cap, inputBytes.length), inputBytes),
  },
};
const instance = await WebAssembly.instantiate(module, imports);

Five functions, and each one is a decision. The clock is coarse on purpose. The log is truncated so a module cannot exhaust memory through it. Input is pulled by the module rather than pushed, so the host controls how much it sees. Nothing here reaches the network, storage or the page.

Four layers, each catching what the last cannot The import object limits what the module can reach, a memory maximum limits how much it can allocate, a worker isolates it from the page, and a deadline with termination bounds how long it can run. worker — terminable, isolated from the page memory maximum — growth fails instead of consuming the device import object — the capability list untrusted module — computes over its own linear memory Remove any one layer and a specific failure becomes possible: a network import, an out-of-memory device, a frozen page, or an infinite loop.

Cap the memory before you run anything

An instance whose memory has no maximum will take as much as the host will give when handed an input that triggers unbounded allocation. Supplying the Memory yourself with an explicit maximum turns that into a clean failure inside the module.

const memory = new WebAssembly.Memory({ initial: 16, maximum: 256 });  // 64 KiB pages

Inside the module, memory.grow returns −1 when it cannot grow, and a well-written module handles that. A badly written one traps, which is also fine — a trap is a caught exception on your side, not a crash of the host. What matters is that neither outcome consumes the device.

Note that the module must import the memory rather than defining its own for this to apply. If the module declares its own memory with no maximum, you cannot impose one, which is itself a reason to require modules to import memory as part of your interface contract.

Bound the time with a worker you can terminate

There is no way to interrupt a running WebAssembly instance from the same thread. A module with an infinite loop will hold that thread forever, and on the main thread that means the tab is gone.

The only browser answer is to run it somewhere you can kill:

function runSandboxed(moduleBytes, input, timeoutMs = 2000) {
  const worker = new Worker('/sandbox-worker.js', { type: 'module' });
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => { worker.terminate(); reject(new Error('timeout')); }, timeoutMs);
    worker.onmessage = ({ data }) => { clearTimeout(timer); worker.terminate(); resolve(data); };
    worker.onerror = (e) => { clearTimeout(timer); worker.terminate(); reject(e); };
    worker.postMessage({ moduleBytes, input }, [moduleBytes]);
  });
}

Terminating a worker is abrupt and complete, which is exactly what you want. Create one per execution rather than reusing it: a fresh worker guarantees a fresh linear memory with no residue from the previous tenant, and worker creation is cheap relative to the work being sandboxed.

Server runtimes: fuel and epochs

Outside the browser, runtimes offer pre-emption that does not require killing a thread. Wasmtime supports fuel metering, where each instruction consumes from a budget, and epoch interruption, where a background thread signals the instance to yield.

let mut config = Config::new();
config.consume_fuel(true);
config.epoch_interruption(true);
let engine = Engine::new(&config)?;

let mut store = Store::new(&engine, ());
store.set_fuel(50_000_000)?;               // a hard ceiling on work
store.limiter(|_| &mut MemoryLimiter { max_bytes: 256 << 20 });

match instance.get_typed_func::<(), i32>(&mut store, "run")?.call(&mut store, ()) {
    Ok(v) => println!("ok: {v}"),
    Err(e) if e.to_string().contains("fuel") => println!("rejected: exceeded work budget"),
    Err(e) => println!("trapped: {e}"),
}

Fuel is deterministic, which makes it suitable for anything that must behave identically across nodes. Epoch interruption is cheaper at runtime and wall-clock based, which suits ordinary request handling. Most deployments use epochs for the timeout and fuel only where determinism matters.

Three ways to stop a module that will not stop A browser terminates the worker, which is abrupt and total. A server runtime can exhaust a deterministic fuel budget or interrupt on an epoch tick, both of which unwind cleanly and report why. worker.terminate() browser, immediate no cleanup, no partial result the only option in a tab fuel exhaustion counts instructions deterministic across nodes small runtime overhead epoch interruption wall-clock, cheap unwinds with a trap default for request handling All three assume you decided the limit in advance. A sandbox without a declared budget is a sandbox with an unbounded one.

Inspect imports before running

You can read what a module wants before you let it run, which is a genuine advantage over a JavaScript dependency. Anything in its import list that is not on your allowed set is a rejection, not a discussion.

wasm-objdump -x plugin.wasm | sed -n '/^Import\[/,/^Export\[/p'
# - func[0] sig=0 <host.log>
# - func[1] sig=1 <host.now_ms>
# - func[2] sig=2 <env.emscripten_asm_const_int>

That third import executes arbitrary JavaScript supplied by the module, and providing it hands back everything the sandbox was protecting. Enforce the check in code rather than by review:

const ALLOWED = new Set(['host.log', 'host.now_ms', 'host.input_len', 'host.read_input', 'env.memory']);
for (const imp of WebAssembly.Module.imports(module)) {
  const name = `${imp.module}.${imp.name}`;
  if (!ALLOWED.has(name)) throw new Error(`rejected module: unexpected import ${name}`);
}

WebAssembly.Module.imports works on a compiled module without instantiating it, so the check happens before any of the module’s code can run.

Expected output

A sandbox run reports what happened and why, in terms you can act on:

module    : plugin-4f2a.wasm (128 kB)
imports   : host.log, host.now_ms, host.read_input     ok
memory    : 16 pages initial, 256 maximum
run       : completed in 412 ms, peak 94 pages
output    : 2048 bytes
run       : terminated after 2000 ms deadline
reason    : no result posted — module did not return
action    : worker terminated, tenant notified

Isolate tenants from each other

linear memory is not cleared between calls, so data left by one tenant is readable by the next. If a single instance serves several tenants, that is a data leak with no exploit required — the next module simply reads memory it did not write.

Create an instance per tenant. The compiled WebAssembly.Module can be shared, which keeps this cheap: compilation is the expensive step and instantiation from a compiled module is well under a millisecond. In a server runtime, pooling instances is acceptable only with an explicit reset that zeroes memory, and the safer default remains a fresh instance per request.

What the host still has to enforce The engine gives memory isolation for free. Everything that bounds how much a module can consume is the host's responsibility and is off by default. memory ceiling a maximum on the imported memory, not just an initial size execution budget fuel or an epoch deadline, or a loop runs forever import surface the smallest set of functions the job actually needs output limits a cap on what the module may return, or it fills your heap Defaults are permissive on every one of these; a host that configures none has an unbounded guest. Test each limit with a module that deliberately violates it, or you trust configuration you never ran.

Gotchas

  • Module defines its own memory. You cannot impose a maximum. Require imported memory.
  • Allowing an asm_const or eval-shaped import. Complete sandbox escape.
  • Reusing a worker between tenants. Residual memory is readable.
  • No deadline. The first infinite loop is an outage.
  • Unbounded host buffers. A module that calls log a million times fills your array. Cap everything the host accumulates on the module’s behalf.
  • Trusting the module’s output. Validate it exactly as you would data from the network.

Performance note

Instantiating a 128 kB module from an already-compiled WebAssembly.Module takes 0.2–0.6 ms, so per-tenant instances are affordable at a high rate. Creating and terminating a worker per execution costs 2–8 ms, which matters only if executions are shorter than that — in which case batching within one tenant’s worker is reasonable, provided tenants are never mixed. Fuel metering in Wasmtime costs roughly 5–15% throughput; epoch interruption is under 1%.

Frequently Asked Questions

Is a Wasm sandbox as strong as a process boundary? Different, and weaker in some respects. It is a language-level boundary enforced by the engine, with a much smaller interface than a syscall table. For hostile code, combining it with a process boundary — a cross-origin iframe in a browser, a separate process on a server — is worth the cost.

Can the module detect it is sandboxed? It can observe which imports exist and how long things take, so yes, approximately. That is rarely a problem, but if you are running adversarial code, expect it to behave differently when it notices.

What about untrusted JavaScript instead? Far harder to contain: JavaScript has ambient access to a large platform surface, and the isolation mechanisms available in a page are leaky. Compiling to WebAssembly and executing that is a meaningfully stronger position.

How do I give a module access to data without giving it a capability? Copy the data into its linear memory before it runs, and let it read from a known offset. A pull-based import works too, but a pre-loaded buffer is simpler to reason about because the module has no way to ask for anything it was not given, and the host decides the size before execution begins.

Should the sandbox be allowed to produce side effects at all? Prefer a pure design: input in, output out, effects applied by the host after inspecting the result. That way a misbehaving module produces a rejected result rather than a partial change you have to undo, and the host’s validation runs before anything is committed.

← Back to Cryptography & Untrusted Code