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
.wasmform. - [ ] A worker (browser) or a runtime with resource limits such as Wasmtime (server).
- [ ]
wasm-objdumpfor 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.
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.
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.
Gotchas
- Module defines its own memory. You cannot impose a maximum. Require imported memory.
- Allowing an
asm_constoreval-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
loga 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.
Related
- Designing a Wasm plugin interface — the contract this sandbox enforces.
- Limiting plugin CPU and memory use — resource control in more depth.
- Security implications of Wasm in enterprise apps — the wider threat picture.
← Back to Cryptography & Untrusted Code