QuickJS-WASM Plugin Sandbox Architecture in Instatic
Instatic isolates every server-side plugin inside a multi-layered QuickJS-WASM sandbox that uses Bun Workers for crash isolation, synchronous QuickJS VMs for API-less execution, and permission-gated RPC bridges to enforce security without sacrificing performance.
The QuickJS-WASM plugin sandbox architecture in CoreBunch/Instatic provides a hardened execution environment for untrusted JavaScript code running on the server. By combining WebAssembly-based virtualization with process-level isolation, the system ensures that plugins cannot access Node.js or Bun host APIs, exhaust resources, or crash the main process. This architecture centers on a synchronous QuickJS VM instantiated inside a dedicated Worker, communicating with the host through a strictly controlled set of bridged functions.
The Five-Layer Isolation Model
Instatic implements defense in depth through a hierarchy of isolated layers, each preventing specific classes of failures from propagating.
- Bun Host Process: The main Instatic server that orchestrates plugin lifecycles and manages Worker pools.
- Bun Worker: Each plugin loads in its own
Workerdefined inserver/plugins/pluginWorker.ts, providing a separate event loop and crash containment. - QuickJS-WASM Context: Inside the worker,
server/plugins/quickjs/vm.tsinstantiates a synchronous QuickJS VM where no Node or Bun globals exist. - Bootstrap SDK:
server/plugins/quickjs/bootstrap/src/buildApi.tsevaluates first to register the SDK façade, dispatcher functions (__runLifecycle,__runRoute), and the permission map (TARGET_PERMISSIONS). Theboundary.tsfile in the same directory validates RPC payloads crossing this layer. - Plugin Bundle: The plugin code—an IIFE that populates
globalThis.__plugin_exports—runs atop the bootstrap, exposing only the hooks defined by the SDK.
Resource Limits and Execution Deadlines
Security hinges on硬性 constraints applied before any untrusted code executes.
The server/plugins/quickjs/limits.ts file defines default caps for memory, maximum stack size, and evaluation timeout. The VM factory applies these limits during initialization in vm.ts by configuring the QuickJS runtime.
Every entry into the VM—including bootstrap evaluation, plugin loading, dispatcher calls, and timer callbacks—is wrapped with withSyncDeadline from server/plugins/quickjs/eval.ts. If the wall-clock deadline expires, the VM forcibly aborts the current job, ensuring that infinite loops cannot hang the Worker. This deadline guard also wraps runtime.executePendingJobs when the host pumps the micro-task queue after resolving promises or firing timers.
Permission Enforcement and RPC Bridging
Communication between the sandbox and host uses a synchronous host-function bridge that returns QuickJS Promise objects resolved asynchronously by the host.
The VM registers three core host functions in server/plugins/quickjs/vm.ts:
__hostCall: Forwards RPC calls to the host API__hostSleep: Handles asynchronous delays__log: Captures console output
When the bootstrap runs, it creates the TARGET_PERMISSIONS map in server/plugins/protocol/targets.ts, associating each RPC target with required permission strings. Every invocation through __hostCall checks the plugin's grantedPermissions set against this map, throwing synchronously if unauthorized.
After bootstrap completion, the host stores persistent handles to dispatcher functions in dispatcherHandles. To invoke a plugin hook, the host retrieves the appropriate handle (e.g., __runLifecycle) and calls it via ctx.callFunction, passing JSON string payloads. The server/plugins/quickjs/eval.ts module provides typed helpers—callString, callVoid, and evalJson—to handle these crossings efficiently without re-parsing on the VM side.
Timer Management and Memory Safety
The VM virtualizes setTimeout and setInterval by tracking handles in a pendingTimers map inside server/plugins/quickjs/vm.ts. When the VM disposes, the host iterates this map to clear all timers, preventing callbacks into a destroyed context.
Between asynchronous events, the host drains the VM's micro-task queue using runtime.executePendingJobs under the deadline guard. This ensures that malicious micro-tasks or deeply recursive promises cannot block the Worker indefinitely.
Canvas Module Variant
For canvas module packs, Instatic uses an identical architecture but substitutes server/plugins/modulePackVm.ts for the standard VM factory. This variant uses MODULE_PACK_BOOTSTRAP_SOURCE instead of the standard plugin bootstrap, tailored for module-specific APIs while maintaining the same security boundaries.
Implementation Example
The following TypeScript demonstrates creating a sandboxed plugin VM and invoking lifecycle hooks through the dispatcher:
// Initialize the VM with resource limits and granted permissions
import { createPluginVm } from '@/server/plugins/quickjs/vm';
import type { PluginVmEnv } from '@/server/plugins/quickjs/types';
const env: PluginVmEnv = {
pluginId: 'my-plugin',
grantedPermissions: new Set(['cms.content.read', 'network.fetch']),
};
const pluginVm = await createPluginVm({
pluginSource: `<bundle-IIFE>`, // Output from the plugin-SDK build pipeline
env,
evalTimeoutMs: 5000,
});
// Invoke the 'activate' hook via the bootstrap dispatcher
const resultJson = await pluginVm.__runLifecycle(
JSON.stringify({ hook: 'activate', args: [] })
);
const result = JSON.parse(resultJson);
The host-side implementation of __hostCall serializes responses to JSON, which the VM receives as a resolved Promise:
// Simplified host bridge using eval.ts helpers
import { callString } from '@/server/plugins/quickjs/eval';
async function __hostCall(target: string, args: unknown[]) {
// Resolve against host API (e.g., cms.content.entries.create)
const response = await resolveHostApi(target, ...args);
return JSON.stringify(response); // Returned to VM as resolved Promise
}
Summary
- Process Isolation: Each plugin runs in a dedicated Bun Worker (
server/plugins/pluginWorker.ts) to contain crashes and prevent event-loop pollution. - API-less Execution: The QuickJS-WASM VM (
server/plugins/quickjs/vm.ts) exposes no Node.js or Bun globals, presenting only the typed SDK façade generated bybuildApi.ts. - Resource Guards: Hard limits on memory, stack, and execution time are enforced via
limits.tsandwithSyncDeadlineineval.ts. - Permission Gating: Every RPC validates against
TARGET_PERMISSIONSinserver/plugins/protocol/targets.ts, ensuring plugins access only explicitly granted capabilities. - Clean Lifecycle: Timer tracking and micro-task pumping prevent resource leaks and ensure controlled shutdown without dangling callbacks.
Frequently Asked Questions
How does Instatic prevent plugins from accessing the filesystem or network directly?
The QuickJS-WASM VM runs without Node.js or Bun bindings, so standard APIs like fs or fetch are undefined. All privileged access flows through the bridged __hostCall function, which validates permissions against server/plugins/protocol/targets.ts before executing any host-side operations.
What happens if a plugin enters an infinite loop?
Every VM entry point is wrapped with withSyncDeadline from server/plugins/quickjs/eval.ts. When the configured wall-clock timeout expires, the VM forcibly aborts the current execution context, returning control to the host Worker and preventing the plugin from hanging the system.
Can plugins use setTimeout and setInterval?
Yes, but these are virtualized. The bootstrap registers polyfills that track handles in pendingTimers. The host manages the actual timer queue and invokes callbacks back into the VM, checking deadlines after each batch of micro-tasks to maintain isolation.
How are permissions configured for each plugin?
When creating the VM via createPluginVm in server/plugins/quickjs/vm.ts, the host passes a grantedPermissions Set in the PluginVmEnv configuration. The bootstrap code in server/plugins/quickjs/bootstrap/src/buildApi.ts compares these grants against the TARGET_PERMISSIONS map before allowing any RPC invocation to proceed.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →