Loading Untrusted Plugins Safely
This guide answers one task: take a .wasm file from someone you do not trust and get it running with
every check performed before a single instruction of it executes, and every resource it can consume
bounded.
Prerequisites
- [ ] A published plugin interface; see designing a Wasm plugin interface.
- [ ] A worker in the browser, or a runtime with limits on a server.
- [ ] A schema or validator for whatever the plugin returns.
- [ ] Test modules that misbehave on purpose.
Compile first, inspect, then instantiate
WebAssembly.compile produces a Module without running anything, and a compiled module can be
interrogated. That gap between compiling and instantiating is where every check belongs.
export async function loadPlugin(bytes, policy) {
if (bytes.byteLength > policy.maxBytes) throw new Error(`plugin too large: ${bytes.byteLength}`);
const module = await WebAssembly.compile(bytes); // no code runs yet
const imports = WebAssembly.Module.imports(module);
for (const imp of imports) {
const name = `${imp.module}.${imp.name}`;
if (!policy.allowedImports.has(name)) throw new Error(`unexpected import: ${name}`);
}
const exports = new Map(WebAssembly.Module.exports(module).map((e) => [e.name, e.kind]));
for (const req of ['abi_version', 'alloc', 'run']) {
if (exports.get(req) !== 'function') throw new Error(`missing export: ${req}`);
}
return module;
}
Three checks, none of which requires executing the plugin: it is not absurdly large, it asks only for capabilities you allow, and it provides the functions your host will call. A module failing any of them never becomes an instance.
Import names that should never be allowed
Some imports hand back everything the sandbox was protecting, and they appear routinely in modules built with default toolchain settings rather than through malice.
env.emscripten_asm_const_int and its relatives execute JavaScript source embedded in the module. Any
wasi_snapshot_preview1 import expects a WASI host with filesystem and clock access — legitimate in a
server runtime with preopens configured, and a red flag in a browser plugin that declared no file access.
A __wbindgen import surface implies wasm-bindgen glue, which is fine only if you supply that glue and
have reviewed what it exposes.
The policy should be an allowlist rather than a denylist, because the set of dangerous names grows and the set of names your interface defines does not. If a plugin needs something outside the list, that is a conversation about extending the interface, not an exception at load time.
Impose memory from the host side
If the plugin exports its own memory, you cannot bound it. Require the interface to import memory, and supply one with a maximum:
const memory = new WebAssembly.Memory({ initial: 16, maximum: policy.maxPages });
const instance = await WebAssembly.instantiate(module, { env: { memory }, host: hostFns(memory) });
A module that then tries to grow past the maximum gets a failed memory.grow — a value of −1 it can
handle, or a trap it cannot, and either outcome is contained. Without this, a plugin handed a crafted
input can allocate until the tab dies, which is indistinguishable from a crash in your own code from the
user’s point of view.
One worker per execution
Even a validated plugin can loop forever, and a browser offers no way to interrupt a running instance. Run each execution in a worker created for it and terminated afterwards, whether it succeeded or not.
export function execute(module, input, { timeoutMs = 2000, maxPages = 256 } = {}) {
const worker = new Worker('/plugin-worker.js', { type: 'module' });
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { worker.terminate(); reject(new Error('plugin timeout')); }, timeoutMs);
worker.onmessage = ({ data }) => {
clearTimeout(timer); worker.terminate();
data.error ? reject(new Error(data.error)) : resolve(data.result);
};
worker.onerror = (e) => { clearTimeout(timer); worker.terminate(); reject(e); };
worker.postMessage({ module, input, maxPages }); // Module is structured-cloneable
});
}
WebAssembly.Module can be posted to a worker directly, so the compilation is done once on the main
thread and reused for every execution — the expensive half is not repeated, and the worker only
instantiates.
Validate what comes back
A sandbox constrains what a plugin can reach, not whether its output is sensible. Anything the plugin returns is untrusted input to your application and needs the same treatment as a response from an unknown server.
const raw = await execute(module, encodeInput(doc));
const parsed = JSON.parse(new TextDecoder().decode(raw)); // may throw — catch it
if (!validate(parsed)) throw new Error('plugin returned an invalid result');
if (parsed.items.length > MAX_ITEMS) throw new Error('plugin result too large');
Three things to check, always: that it parses, that it matches your schema, and that its size is within what your application can handle. A plugin returning a hundred megabytes of valid JSON is not attacking you deliberately, but the effect on your renderer is the same as if it were.
Where the bytes came from
Validation of the module’s shape says nothing about its provenance. A plugin that passes every structural check can still be a different plugin from the one the user meant to install, if the path from author to host is not protected.
Three measures, in increasing order of effort. Pin by content hash: the installed record stores the hash
of the exact bytes that were reviewed, and the loader refuses anything that does not match. This alone
defeats a substituted module on a compromised mirror, and it costs one crypto.subtle.digest call at
load.
Verify a signature: the author signs the module bytes, the host checks the signature against a key it knows, and an attacker who controls distribution still cannot produce a module that verifies. This needs key management and a revocation story, which is why it belongs to a registry rather than a sideloading path.
Review at submission: automate the same import and export checks in the publishing pipeline, so a module that would be rejected at load never reaches a user at all. That turns a runtime failure into a submission error the author can fix, which is a much better experience for everyone.
const digest = await crypto.subtle.digest('SHA-256', bytes);
if (toHex(digest) !== installed.sha256) throw new Error('plugin bytes do not match the installed record');
For a sideloading path where the user picks a local file, none of this applies and the trust decision is theirs — which is acceptable for a developer tool and worth stating plainly in the interface, so nobody assumes a review happened that did not.
Expected output
A load and execution with logging shows each gate passing, which is also what you want in production telemetry:
plugin : acme-formatter 1.4.0 (72 kB)
size : ok (limit 2 MB)
imports : host.log, host.config_len, host.read_config ok
exports : abi_version, alloc, dealloc, run ok
abi : 1 ok
execute : 84 ms, peak 41 pages (limit 256)
output : 12 kB, schema ok
plugin : suspicious-thing 0.1.0 (1.9 MB)
imports : host.log, wasi_snapshot_preview1.fd_write REJECTED
result : not instantiated
Gotchas
- Instantiating before checking.
WebAssembly.instantiateon bytes compiles and runs the start function in one step. Compile separately. - A
startsection. A module can declare code that runs at instantiation, before you call anything. Your limits must already be in place at that moment — which they are if memory is imported and the instantiation happens inside the worker. - Denylisting imports. Use an allowlist; the dangerous set is open-ended.
- Reusing the worker. Residual memory leaks data between plugins and between tenants.
- No limit on plugin count. A user who installs forty plugins triggers forty executions per action. Bound concurrency and total installs.
- Error messages leaking internals. Report that a plugin failed and which one, not your host’s stack.
Performance note
Compiling a 72 kB module takes 3–8 ms and is done once. Posting the compiled module to a worker and instantiating it costs about 1.5 ms, and worker creation and teardown add 2–8 ms — so a per-execution worker is affordable for anything taking more than about ten milliseconds of real work. For very frequent short executions, keep one worker per plugin per tenant and recreate it periodically, accepting a slightly weaker isolation story in exchange.
Frequently Asked Questions
Can I scan a module for dangerous behaviour statically? Only shallowly. You can see imports, exports, memory declarations and section sizes, which catches the structural problems. What the code does with what you give it is not decidable, which is exactly why the limits exist.
Is it safe to run untrusted plugins on the server? Yes, with a runtime that enforces fuel or epoch limits, memory caps and no default capabilities. Wasmtime with an empty WASI context and explicit preopens is a reasonable starting point, and it gives you cleaner pre-emption than a browser can.
What if a plugin needs network access? Mediate it. A host function that fetches from an allowlist the host controls gives the plugin what it needs without handing over the capability itself, and it keeps the decision — and the audit log — on your side.
Related
- Limiting plugin CPU and memory use — the budgets in detail.
- Sandboxing untrusted code with Wasm — the same problem from the security side.
- Designing a Wasm plugin interface — the contract being enforced here.
← Back to Plugin Systems & Extensibility