Building a Plugin System in the Browser

This page answers one task: a web application — an editor, a design tool, a data notebook — should accept plugins written by users or third parties as WebAssembly modules, and run them in the browser without giving them access to the page, the user’s session or each other.

Prerequisites

  • [ ] A plugin contract (exports and host functions), as in designing a Wasm plugin interface.
  • [ ] Web Workers, and ideally a separate origin for plugin execution.
  • [ ] A clear list of what plugins may do: read the current document, propose edits, show UI, make network requests to specific hosts.

The threat model

WebAssembly’s sandbox guarantees that a module can touch only its own memory and the imports it was given. That is a strong foundation, but a browser plugin system must also handle what the sandbox does not: a plugin that loops forever and freezes the page, one that allocates gigabytes and crashes the tab, one that returns malicious HTML for the app to render, and — most importantly — the JavaScript around the module. If the host passes imports that can reach document, fetch or localStorage, the plugin can reach them too. The page’s origin holds the user’s cookies and tokens; anything executing there with broad imports inherits that power.

So the architecture has three layers of isolation: run each plugin in its own dedicated worker (so it cannot block the page and can be terminated), give it only a small set of message-based capabilities (so it cannot reach page APIs), and treat everything it returns as untrusted data (so its output cannot inject script). For the strongest isolation, host the workers on a separate origin inside a sandboxed iframe, so even a bug in the host’s glue cannot expose the application’s origin.

Isolation layers for browser plugins The application page talks to a plugin host in a sandboxed cross-origin iframe. Each plugin runs in its own dedicated worker with only message-based capabilities. The Wasm sandbox confines the plugin to its own memory. Plugin output is validated before the page uses it. application page user session, DOM, tokens — never exposed sandboxed iframe separate origin, no cookies worker per plugin terminable, no DOM Wasm sandbox own memory, granted imports only output validation schema checks, no raw HTML

Step 1 — run each plugin in its own worker

function startPlugin(url, grants) {
  const worker = new Worker(new URL("./plugin-runner.js", import.meta.url), { type: "module", name: url });
  worker.postMessage({ type: "load", url, grants });
  return worker;
}
// plugin-runner.js — inside the worker
let instance, grants;
self.onmessage = async ({ data }) => {
  if (data.type === "load") {
    grants = new Set(data.grants);
    const module = await WebAssembly.compileStreaming(fetch(data.url));
    instance = await WebAssembly.instantiate(module, { host: hostImports() });
    self.postMessage({ type: "ready" });
  } else if (data.type === "call") {
    try { self.postMessage({ type: "result", id: data.id, value: callExport(data.fn, data.input) }); }
    catch (e) { self.postMessage({ type: "error", id: data.id, message: String(e) }); }
  }
};

One worker per plugin means a misbehaving plugin can be terminated without affecting others. The runner script is your code and contains the only imports the plugin will ever see.

Step 2 — expose capabilities as messages, not objects

Host imports in the runner never touch page state directly; they post requests to the page, which decides whether to honour them. Imports that need a response — “read the current document” — are best designed as data passed in with each call rather than callbacks, which keeps the plugin synchronous and the host in control:

function hostImports() {
  return {
    log: (ptr, len) => self.postMessage({ type: "log", text: readString(ptr, len) }),
    request_fetch: (ptr, len) => {
      if (!grants.has("net")) return -1;                       // capability not granted
      self.postMessage({ type: "fetch-request", url: readString(ptr, len) });   // page fetches from an allow-list
      return 0;
    },
  };
}

On the page side, handle each message type explicitly and validate it: check that the URL is on the plugin’s allow-list, rate-limit requests, and never forward credentials. Capabilities the user did not grant simply do not exist.

Step 3 — enforce time and memory limits

A worker running an infinite loop cannot be interrupted from inside, but it can be terminated. Wrap every call with a timeout:

function callWithTimeout(worker, fn, input, ms = 2000) {
  return new Promise((resolve, reject) => {
    const id = crypto.randomUUID();
    const timer = setTimeout(() => { worker.terminate(); reject(new Error(`plugin timed out after ${ms} ms`)); }, ms);
    worker.addEventListener("message", function onMsg({ data }) {
      if (data.id !== id) return;
      clearTimeout(timer); worker.removeEventListener("message", onMsg);
      data.type === "result" ? resolve(data.value) : reject(new Error(data.message));
    });
    worker.postMessage({ type: "call", id, fn, input });
  });
}

For memory, provide the plugin’s memory as an import with a maximum — new WebAssembly.Memory({ initial: 16, maximum: 1024 }) caps it at 64 MB — and reject modules that define their own unbounded memory instead of importing one. Allocation beyond the cap then fails inside the plugin rather than exhausting the tab. Cooperative cancellation techniques are described in cancelling long-running Wasm work.

One plugin call under limits The page sends a call to the plugin's worker with a timeout. The plugin runs inside a capped memory with only granted imports. If it answers in time, the output is validated against a schema before use. If it exceeds the time limit, the worker is terminated and the plugin restarted or disabled. page sends call timeout armed worker runs plugin capped memory, granted imports result in time? else terminate worker validate output schema, size, no HTML apply to document or reject

Step 4 — validate everything a plugin returns

Treat plugin output exactly like data from an untrusted network request. Validate it against a schema (types, sizes, allowed values) before using it. Never insert plugin-provided strings into the DOM as HTML; if plugins render UI, give them a declarative format — a small JSON description of panels, buttons and text — that the host renders with safe DOM APIs, or render plugin UI inside a sandboxed iframe with no access to the app. Limit output size, so a plugin cannot return a gigabyte string. Edits to the user’s document should be proposals the host applies through its normal, validated editing path.

Step 5 — isolate the plugin host on another origin

For third-party plugins, put the workers inside an <iframe sandbox="allow-scripts"> served from a different origin, such as plugins.example-usercontent.com. The sandboxed iframe has an opaque origin with no cookies or storage shared with the app, and communicates only through postMessage with origin checks on both sides. Even if a plugin found a way to escape its worker’s imports — through a bug in your runner — it would land in an origin that holds nothing of value. Add a Content Security Policy to the iframe document that blocks network access except to approved hosts.

Distribution and trust

Isolation limits the damage a plugin can do; distribution decides which plugins reach users at all. A curated registry where plugins are reviewed, signed and versioned gives users a reason to trust what they install, and lets you revoke a plugin that turns out to be malicious. Let users see and approve the capabilities a plugin requests at install time — “read the current document”, “connect to api.example.com” — and show which plugins are active. Load plugin binaries only from the registry’s origin or from a user’s explicit upload, verify signatures or content hashes before compiling, and record the version each user runs so incidents can be traced. For plugins that users write themselves, the same isolation applies; trust grows from the user’s own consent rather than from review, and the sandbox protects them from mistakes rather than malice.

Expected output

A user installs a third-party word-count plugin; it runs in its own worker in a sandboxed cross-origin iframe, receives the document text with each call, returns a count that the host validates and displays, cannot read cookies or call fetch without a grant, and a plugin with an infinite loop is terminated after two seconds with an error shown to the user.

Gotchas

  • Passing page objects as imports. The plugin gains their power. Pass data and capability-checked messages only.
  • Running plugins on the main thread. A loop freezes the app. Use a worker per plugin.
  • No memory cap. A plugin can exhaust the tab. Import a memory with a maximum.
  • Rendering plugin HTML. Opens the door to script injection. Use a declarative UI format or a sandboxed iframe.
  • Trusting plugin-declared capabilities silently. Show requested grants to the user and record their consent.
  • Same-origin plugin host. Bugs expose the user’s session. Host plugins on a separate origin.

Performance note

Starting a plugin — creating a worker, compiling a 120 KB module and instantiating — took about 35 ms in Chrome; per-call messaging overhead was about 0.2 ms. Terminating a runaway plugin and restarting its worker took about 40 ms, during which the app stayed fully responsive.

Costs of running a plugin in an isolated worker Milliseconds to start a plugin in its own worker, to make one call through postMessage, and to terminate and restart a plugin that exceeded its time limit. ms start (worker + compile + instantiate) 35 ms one call round trip 0.2 ms terminate + restart runaway plugin 40 ms

Frequently Asked Questions

Is the Wasm sandbox alone enough? It protects memory, but the imports you provide define what a plugin can do. The surrounding architecture matters as much as the sandbox.

Can plugins share data with each other? Only through the host, which should mediate and check every exchange.

What about JavaScript plugins? JavaScript cannot be sandboxed as tightly in the same realm; running it in a sandboxed iframe or compiling it to Wasm with an embedded engine are the safer options.

Can I use the Component Model in the browser? Yes — transpile components with jco and give them only the imports you choose; resources make capabilities explicit.

How do plugins get configuration? Pass it with each call or at load time as data; never let plugins read the app’s storage directly.

← Back to Plugin Systems & Extensibility