How Instatic Uses QuickJS-WASM Sandbox for Secure Plugin Isolation

Instatic isolates untrusted server-side plugins using a QuickJS-WASM sandbox running inside a Bun Worker, enforcing strict memory and CPU limits while exposing a permission-checked SDK API through host functions.

The CoreBunch/Instatic repository implements a deterministic, resource-bounded execution environment where third-party JavaScript plugins run without direct access to the host filesystem or network. By combining the QuickJS interpreter compiled to WebAssembly with Bun's multi-threading capabilities, Instatic creates a defense-in-depth architecture that prevents plugins from crashing the main server process or exhausting system resources.

QuickJS-WASM Sandbox Architecture

Instatic's security model relies on nested isolation layers. At the outermost level, each plugin executes inside a Bun Worker that provides crash isolation and CPU yielding. Within that worker, the QuickJS-WASM interpreter (from the quickjs-emscripten package) creates a separate JavaScript context per plugin.

Isolation Layers

The topology minimizes attack surface by ensuring the host process never shares objects directly with the VM:


Bun host (main process)
 └─ Bun.Worker (crash-isolation, CPU-yield)
      └─ QuickJS-WASM context (security sandbox)
           ├─ Bootstrap (SDK façade + handler registries)
           └─ Plugin source (IIFE → globalThis.__plugin_exports)

Communication occurs exclusively through registered host functions (__hostCall, __hostSleep, __log). These functions act as the only bridge between the sandboxed code and the host environment.

Resource Limits

Before executing any user code, Instatic enforces hard caps on memory and stack usage. In server/plugins/quickjs/vm.ts, the createPluginVm function initializes the VM with DEFAULT_MEMORY_LIMIT_BYTES and DEFAULT_STACK_SIZE_BYTES, preventing plugins from allocating unbounded memory or causing stack overflow errors.

Bootstrap Process and Dispatcher Registration

The VM initialization follows a two-stage evaluation process that separates the runtime infrastructure from untrusted plugin code.

Bootstrap Bundle Evaluation

First, the VM evaluates BOOTSTRAP_SOURCE defined in server/plugins/quickjs/bootstrap/index.ts. This bootstrap bundle installs:

  • Dispatcher functions (__runLifecycle, __runRoute, etc.) that the host will later invoke
  • Polyfills for standard timers (setTimeout, clearTimeout) that forward to the host

The bootstrap creates a controlled environment where plugin code can register handlers without accessing dangerous globals.

Dispatcher Function Registration

After bootstrap completion, the actual plugin bundle executes as an IIFE that populates globalThis.__plugin_exports. The host retrieves persistent handles for each dispatcher listed in DISPATCHER_NAMES (defined in server/plugins/quickjs/vm.ts) and stores them in dispatcherHandles.

When the host needs to trigger plugin logic, it uses the generic helpers callString, callVoid, and others from server/plugins/quickjs/eval.ts to invoke these handles safely across the WebAssembly boundary.

Secure Host-Plugin Communication

All interactions between the sandboxed plugin and the Instatic server flow through a strictly controlled API surface.

Host Functions and SDK Facade

The bootstrap exposes env.hostCall to plugin code, which maps to the host-side __hostCall function. This is the only mechanism for plugins to request server-side operations. The host implementation in server/host/apiDispatch.ts validates permissions through dispatchApiCall before executing any api.plugin.* or api.cms.* operations.

Plugins cannot access the filesystem, network, or other system resources directly. They must serialise requests through this permission-checked channel, with the host returning only JSON-serializable results.

Micro-Task Draining

QuickJS maintains its own internal promise queue and timer callbacks. To prevent the VM from stalling the worker thread, Instatic explicitly drains pending jobs after every host-side promise resolution or timer event using runtime.executePendingJobs() (wrapped in pumpPendingJobs within server/plugins/quickjs/vm.ts). This guarantees that asynchronous plugin code yields control back to the host regularly.

Graceful Shutdown and Resource Cleanup

When a plugin is unloaded or reloaded, Instatic performs deterministic cleanup to prevent memory leaks and use-after-free vulnerabilities.

The dispose() method in server/plugins/quickjs/vm.ts executes the following sequence:

  1. Marks the VM as dead to reject new incoming calls
  2. Clears all pending timers
  3. Disposes any still-pending Deferred promises
  4. Releases all host-function handles
  5. Destroys the QuickJS context

This ordered disposal ensures that dangling references cannot crash the worker or leak sensitive host functions.

Creating a Sandboxed Plugin VM

The public API surface abstracts the complexity of the QuickJS-WASM implementation. The following example demonstrates spinning up a sandboxed VM and executing plugin lifecycle hooks:

import { createPluginVm, type PluginVmEnv } from '@/server/plugins/quickjs/vm';

// 1️⃣ Build the host environment that the VM sees
const env: PluginVmEnv = {
  pluginId: 'my-awesome-plugin',
  manifestVersion: '1.0.3',
  grantedPermissions: ['cms.read', 'cms.write'],
  assetBasePath: '/uploads/plugins/my-awesome-plugin/1.0.3',
  settings: { theme: 'light' },

  // Host-side implementation of the SDK API
  async hostCall(target, args) {
    // Validates permission and dispatches to real handler
    return await dispatchApiCall(target, args);
  },

  // Simple fire-and-forget logger
  log(args) {
    console.log('[plugin:my-awesome-plugin]', ...args);
  },
};

// 2️⃣ Load the compiled plugin bundle (generated by the SDK)
const pluginSource = await Bun.file('./plugins/my-awesome-plugin/dist/plugin.js').text();

// 3️⃣ Spin up a sandboxed VM with resource limits
const vm = await createPluginVm({ pluginSource, env });

// 4️⃣ Run lifecycle hooks (install, activate, etc.)
await vm.runLifecycle('install');

// 5️⃣ Execute a custom route defined by the plugin
const result = await vm.runRoute('myRoute', {
  request: { url: '/api/foo', method: 'GET', headers: {}, body: '' },
  body: {},
  user: null,
});
console.log('Route result →', result);

// 6️⃣ Clean up when the plugin is removed or reloaded
vm.dispose();   // Releases all QuickJS resources safely

The PluginVm type returned by createPluginVm exposes strongly-typed async methods (runLifecycle, runRoute, runHookListener, runSchedule) that internally forward to the QuickJS dispatcher functions while handling marshalling through server/plugins/quickjs/marshal.ts.

Summary

  • Nested Isolation: Instatic uses Bun Workers containing independent QuickJS-WASM contexts to prevent plugins from crashing the main process or accessing host memory directly.
  • Resource Enforcement: Hard memory and stack limits (DEFAULT_MEMORY_LIMIT_BYTES, DEFAULT_STACK_SIZE_BYTES) are applied per-plugin in vm.ts before any untrusted code executes.
  • Controlled Communication: Plugins interact with the host solely through env.hostCall, which validates permissions in apiDispatch.ts before processing api.plugin.* or api.cms.* requests.
  • Deterministic Cleanup: The dispose() method in vm.ts releases all handles, clears timers, and destroys the QuickJS context in a specific order to prevent use-after-free errors.

Frequently Asked Questions

What is QuickJS-WASM and why does Instatic use it?

QuickJS-WASM is the QuickJS JavaScript interpreter compiled to WebAssembly using Emscripten. Instatic uses it because it provides a lightweight, embeddable JS engine that runs inside a sandboxed WebAssembly memory space, offering stronger isolation than running code directly in the host Bun process while maintaining near-native performance for plugin logic.

How does Instatic prevent plugins from blocking the event loop?

Instatic implements explicit micro-task draining using runtime.executePendingJobs() (wrapped in pumpPendingJobs) after every host function call returns. This forces the QuickJS VM to process all pending promise continuations and timers immediately, preventing infinite loops or long-running synchronous operations from stalling the Bun Worker thread.

Can plugins access the filesystem or network directly?

No. Plugins have zero direct access to system resources. All file system, database, and network operations must route through env.hostCall, which validates the plugin's granted permissions (defined in PluginVmEnv.grantedPermissions) before dispatching to the actual implementation in server/host/apiDispatch.ts. The QuickJS-WASM sandbox has no bindings to system APIs.

What happens if a plugin exceeds its memory limit?

If a plugin attempts to allocate beyond DEFAULT_MEMORY_LIMIT_BYTES or exceeds DEFAULT_STACK_SIZE_BYTES, the QuickJS runtime throws an out-of-memory error that propagates to the host. The host can then catch this error and trigger vm.dispose() to terminate the plugin without affecting other plugins or the main Bun process, ensuring strict resource containment per-plugin.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →