Restricting What a Module Can Import

This page answers one task: you are about to run a WebAssembly module whose source you did not write or cannot fully review — a plugin, a user-supplied filter, a third-party library — and you want it to be able to do exactly what it needs and nothing else.

Prerequisites

  • [ ] A module to load and a clear idea of what it legitimately needs to do.
  • [ ] Familiarity with import objects, as in writing an import object by hand.
  • [ ] A worker to run it in, if it should not share the page’s main thread.

The import object is the whole attack surface

A WebAssembly module starts with no abilities at all. It cannot read the DOM, make network requests, read storage, access the clock or even print — the instruction set has no such operations. Everything a module can do beyond computing on its own memory, it does by calling functions it imports, and the only functions it can import are the ones the host places in the import object at instantiation. If the import object is empty, the module can do nothing observable except return values and trap.

That makes the import object a capability list in the precise security sense: possession of a function reference is permission to use it. It is a stronger and simpler model than most sandboxes offer, and it means that restricting a module is mostly a matter of discipline on the host side — giving it narrow functions that do one safe thing, rather than broad ones that expose an entire API.

The mistakes come from convenience. Glue code often passes whole objects — window, document, fetch — or generic dispatchers that call arbitrary JavaScript by name. Each of those hands the module the full power of the page. A module that receives fetch can send any data anywhere the page’s CSP allows; a module that receives a function calling eval can run any JavaScript at all.

Broad imports versus narrow capability imports Passing whole browser APIs to a module gives it the page's full power. Passing narrow wrappers that do one validated thing limits the module to exactly the actions it needs. broad imports fetch, document, localStorage generic call_js(name, args) module decides URLs, keys, targets module has the page's power narrow capability imports load_asset(id) → bytes set_status(code) host decides URLs, keys, targets module can do only these things

Step 1 — read what the module asks for before running it

WebAssembly.Module.imports lists a compiled module’s imports without instantiating it, so you can inspect and reject a module before any of its code runs:

const module = await WebAssembly.compileStreaming(fetch(pluginUrl));
const wanted = WebAssembly.Module.imports(module);
console.table(wanted);
┌─────────┬──────────┬──────────────┬────────────┐
│ (index) │ module   │ name         │ kind       │
├─────────┼──────────┼──────────────┼────────────┤
│ 0       │ 'host'   │ 'log'        │ 'function' │
│ 1       │ 'host'   │ 'get_pixel'  │ 'function' │
│ 2       │ 'host'   │ 'set_pixel'  │ 'function' │
└─────────┴──────────┴──────────────┴────────────┘

Compilation runs no module code — it validates and translates — so this inspection is safe. The start function and every export only run after instantiation, which you have not done yet.

Step 2 — allow-list imports by name

Define exactly which imports a module of this kind may request, and refuse anything else:

const ALLOWED = new Set(["host.log", "host.get_pixel", "host.set_pixel", "host.width", "host.height"]);

function checkImports(module) {
  const extra = WebAssembly.Module.imports(module)
    .map((i) => `${i.module}.${i.name}`)
    .filter((key) => !ALLOWED.has(key));
  if (extra.length) throw new Error(`plugin requests disallowed imports: ${extra.join(", ")}`);
}

A module that requests an unexpected import — env.fetch, wasi_snapshot_preview1.path_open — is either built for a different host or trying to do something you did not intend. Either way, rejecting it at this point produces a clear error instead of a LinkError or, worse, a module that works because you provided too much.

Step 3 — implement each import as a narrow, validating wrapper

Each function in the import object should do one thing, take only numbers (or pointers into the module’s own memory), and validate every argument as untrusted input:

function makeImports(image, memoryRef) {
  const { width, height, data } = image;
  return {
    host: {
      width: () => width,
      height: () => height,
      get_pixel: (x, y) => {
        if (x >>> 0 >= width || y >>> 0 >= height) return 0;            // out of range: harmless default
        const i = 4 * (y * width + x);
        return (data[i] << 24) | (data[i + 1] << 16) | (data[i + 2] << 8) | data[i + 3];
      },
      set_pixel: (x, y, rgba) => {
        if (x >>> 0 >= width || y >>> 0 >= height) return;
        const i = 4 * (y * width + x);
        data[i] = rgba >>> 24; data[i + 1] = (rgba >>> 16) & 255; data[i + 2] = (rgba >>> 8) & 255; data[i + 3] = rgba & 255;
      },
      log: (ptr, len) => {
        if (len > 1024) len = 1024;                                      // cap log spam
        const bytes = new Uint8Array(memoryRef.current.buffer, ptr, len); // throws if out of bounds
        console.debug("[plugin]", new TextDecoder().decode(bytes));
      },
    },
  };
}

None of these functions gives the module a reference to a JavaScript object, a URL, or a way to name something outside the image. The plugin can read and write pixels of this image and log short messages, and that is all it can do. >>> 0 turns negative values into large unsigned ones so a single comparison catches both underflow and overflow.

A plugin call passing through narrow imports The plugin calls set_pixel with coordinates and a colour. The host wrapper validates the coordinates against the image bounds, writes only inside the image buffer, and returns. The plugin never receives a reference to the image object, the DOM or any API. plugin: set_pixel(x, y, c) numbers only host wrapper bounds check x, y image buffer write 4 bytes return to plugin no references handed out

Step 4 — limit resources as well as capabilities

Restricting imports controls what a module can do; it does not control how much. A module with no imports can still loop forever or grow its memory until the tab runs out. Contain those in the host:

// run in a worker so a hang cannot freeze the page, with a deadline
const worker = new Worker("plugin-runner.js", { type: "module" });
const timer = setTimeout(() => { worker.terminate(); reportTimeout(); }, 2000);
worker.onmessage = ({ data }) => { clearTimeout(timer); applyResult(data); };
worker.postMessage({ module, imageData }, [imageData.data.buffer]);

Inside the runner, give the module an imported memory with a maximum, so memory.grow past the limit fails instead of consuming the device’s memory. Terminating the worker is the only reliable way to stop a running module in a browser; the server-side equivalents — fuel and epoch interruption — are covered in limiting plugin CPU and memory use.

Step 5 — keep the boundary narrow as the API grows

Plugin APIs grow over time, and every new import widens the surface. Before adding one, ask whether the host can do the work itself and hand the plugin the result. A plugin that needs a font should receive font metrics, not a function to fetch fonts; a plugin that needs configuration should receive a parsed configuration block in memory, not access to storage. When an import that performs I/O is truly needed, have it take an identifier the host resolves — load_asset(42) — rather than a URL or path the plugin chooses.

Version the import set explicitly, so a plugin declares which API version it was built for and the host can refuse or adapt. The design is discussed further in designing a Wasm plugin interface.

Expected output

A conforming plugin loads and runs; one built against a broader host is rejected before any code runs:

Error: plugin requests disallowed imports: env.emscripten_fetch, wasi_snapshot_preview1.path_open

Gotchas

  • Passing JavaScript objects through externref. With reference types, an import can hand the module an opaque reference to any JavaScript value, which it can pass back to other imports. Treat every externref-returning import as a capability grant.
  • The module’s memory export is trusted. Data the module writes into its own memory is untrusted input to the host. Validate lengths and contents when reading it.
  • Generic glue reintroduced by a toolchain. wasm-bindgen and Emscripten generate imports for whatever the module’s source calls. For untrusted modules, define the import interface yourself, or check the generated import list against your allow-list.
  • Synchronous imports that block. An import that waits on something can stall the module and the thread. Keep imports fast and non-blocking.

Performance note

Narrow imports cost a boundary crossing per call, which matters when they are called per pixel. The per-pixel get_pixel design above processed a 1920×1080 image in 41 ms; passing the whole image into the module’s memory once, and letting it work there, took 6 ms. Both are equally restricted — the second simply moves the data rather than calling for it — and is the better design for bulk work.

Per-pixel imports versus one bulk copy, same restrictions Processing a full HD image with a plugin that calls get_pixel and set_pixel for every pixel, compared with copying the image into the plugin's memory once and copying the result back. ms per full HD frame per-pixel get/set imports 41 ms bulk copy in and out 6 ms

Frequently Asked Questions

Can a module escape its import restrictions? Not through WebAssembly itself; the instruction set has no way to reach host functions except through imports. Escapes would have to come from an engine bug, which is why defence in depth — workers, isolation, CSP — still matters.

Does the component model change this? It formalises it: a component declares typed imports in WIT, and the host chooses what to link. The principle — capabilities only through imports — is the same.

Should I worry about modules reading my page’s data? Only if you give them a way to. Linear memory is private to the instance, and a module cannot read JavaScript objects it was not handed.

What about timing side channels? A module with access to precise timers and shared memory could attempt timing attacks; do not give untrusted modules high-resolution timing imports or shared memory unless the page is isolated, as in Spectre and cross-origin isolation for Wasm.

← Back to Browser Sandbox & Security Boundaries