How the QuickJS-WASM Plugin Sandbox Isolates Code in Instatic
The QuickJS-WASM plugin sandbox in Instatic uses a three-layer isolation architecture: Bun.Worker threads for crash containment, QuickJS running inside WebAssembly for security boundaries, and strict resource limits with a controlled host-call bridge to prevent unauthorized system access.
Instatic's server-side plugin system is built on a sophisticated isolation model that prevents untrusted code from compromising the host server. According to the CoreBunch/Instatic source code, every plugin executes inside a QuickJS-WASM sandbox that combines multiple defensive layers. This article breaks down exactly how that isolation works, with direct references to the implementation files.
Three-Layer Architecture Overview
The sandbox stack creates defense in depth through progressive isolation:
┌─ Bun host (main process)
│ ┌─ Bun.Worker (crash-isolation, CPU yielding)
│ │ ┌─ QuickJS-WASM context (security sandbox)
│ │ │ ┌─ Bootstrap (SDK façade, handler registries, polyfills)
│ │ │ └─ Plugin source (IIFE → globalThis.__plugin_exports)
│ │ └─ Host functions: __hostCall, __hostSleep, __log
│ └─ protocol / wire format
└─ host / API-call dispatch
Each layer addresses a specific threat model: process-level crashes, system-level access, and resource exhaustion.
Layer 1: Bun.Worker Thread Isolation
In server/plugins/quickjs/vm.ts, each plugin spawns in a separate Bun.Worker. This provides two critical protections:
- Crash containment — A plugin that segfaults or hits an unrecoverable error terminates only its own worker, not the main server process
- Event-loop separation — Timers and asynchronous operations run on the worker's isolated event loop, preventing the main process from being blocked by slow or malicious plugin code
The worker pattern ensures that no single plugin can bring down the entire Instatic instance.
Layer 2: QuickJS-WASM Security Boundary
Inside each worker, the actual JavaScript execution happens in QuickJS compiled to WebAssembly. This is the core of the security model implemented in server/plugins/quickjs/vm.ts:
- No filesystem access — QuickJS-WASM has no native bindings to the host filesystem
- No network sockets — Direct network I/O is impossible from within the WASM boundary
- No Node/Bun globals — Standard library functions like
process.exit()orDeno.readFile()simply do not exist
The VM is created fresh for each plugin with a minimal, audited set of host functions injected explicitly:
// From server/plugins/quickjs/vm.ts — host function registration
const vm = await createPluginVm({
pluginSource: compiledPluginIIFE,
env: {
pluginId: 'my-plugin',
grantedPermissions: ['cms.routes.register'],
hostCall: async (target, args) => {
// Host implements permission checks here
if (target === 'cms.routes.register') { /* ... */ }
throw new Error('Unauthorized host call');
},
// Only explicit capabilities are forwarded
log: (msg) => console.info('[my-plugin]', ...msg),
},
});
Layer 3: Bootstrap Environment and SDK Facade
Before any plugin code executes, the sandbox evaluates a bootstrap bundle from server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts. This serves as the controlled runtime environment:
__plugin_handlers— Global registry where plugins register lifecycle hooks- Dispatcher functions —
__runLifecycle,__runRoute, and others that the host calls to invoke plugin code safely - Read-only metadata —
__plugin_metaexposes manifest data that plugins cannot modify - Mutable settings —
__plugin_settingsprovides user configuration with type-safe access
The bootstrap exposes only three host-capability polyfills:
| Polyfill | Purpose | Implementation Location |
|---|---|---|
__hostCall |
Controlled API access to host | server/plugins/quickjs/vm.ts |
__hostSleep |
Timer functionality without native setTimeout |
Bootstrap + worker tracking |
__log |
Structured logging to host | Injected host function |
Plugin source code is an IIFE that assigns to globalThis.__plugin_exports:
// Inside the sandbox (bootstrap handles registration)
globalThis.__plugin_exports = {
async activate() {
// Plugin lifecycle hook
},
async myRoute(req) {
// Route handler
}
};
The bootstrap discovers these exports via __detectExportedHooks and validates them before the host can invoke them.
Strict Resource Limits in server/plugins/quickjs/limits.ts
The sandbox enforces three hard caps defined in server/plugins/quickjs/limits.ts:
Memory Limit
setMemoryLimit(64 * 1024 * 1024); // 64 MiB per VM
Exceeding this throws OutOfMemory inside the VM, caught and reported by the host.
Stack Limit
setMaxStackSize(1024 * 1024); // 1 MiB
Prevents infinite recursion from exhausting the WASM stack and crashing the worker.
Wall-Clock Deadline
From server/plugins/quickjs/eval.ts, every evaluation uses withSyncDeadline:
const DEFAULT_EVAL_TIMEOUT_MS = 5000; // 5 seconds
// All plugin code entry points are wrapped
await withSyncDeadline(vm, () => {
return vm.runRoute('myRoute', context);
}, DEFAULT_EVAL_TIMEOUT_MS);
If a plugin loops indefinitely, the VM is aborted and the job logged.
The Host-Call Bridge: Controlled Escape Hatch
The only way plugin code reaches the host is through __hostCall, implemented in server/plugins/quickjs/vm.ts. This synchronous function creates a carefully controlled async pattern:
- Plugin calls
__hostCall(target, args)synchronously - VM-side creates a
Promiseand returns its handle immediately - Host receives the call, executes the requested operation
- Host resolves the promise from outside
runtime.executePendingJobs()drains the micro-task queue
This design prevents plugins from calling arbitrary host APIs — the host implementation validates every target against the plugin's grantedPermissions before executing.
Timer Isolation and Cleanup
Timer functionality uses __hostSleep with full tracking in server/plugins/quickjs/vm.ts:
- All
setTimeout/setIntervalpolyfills are tracked inpendingTimers - On
dispose(), timers are cleared before VM teardown - Prevents "fire-into-dead-context" crashes where a timer callback runs after the VM is destroyed
Complete Cleanup on Plugin Unload
The dispose() method in server/plugins/quickjs/vm.ts implements deterministic cleanup:
vm.dispose(); // When plugin is removed or reloaded
This sequence:
- Marks VM as disposed to short-circuit pending host calls
- Clears
pendingTimersand disposesDeferredpromises - Releases all host-function and dispatcher handles
- Finally disposes the QuickJS context via
context.dispose()
Summary
- Bun.Worker provides process-level crash isolation and event-loop separation
- QuickJS-WASM eliminates direct system access through the WebAssembly security boundary
- Bootstrap bundle in
server/plugins/quickjs/bootstrap/generated/pluginBootstrap.tscreates a controlled runtime with explicit capability grants - Resource limits in
server/plugins/quickjs/limits.tscap memory (64 MiB), stack (1 MiB), and execution time (5s default) __hostCallbridge is the sole escape hatch, with synchronous invocation and async resolution preventing arbitrary API access- Comprehensive cleanup in
dispose()eliminates resource leaks and timer-related crashes
Together, these mechanisms ensure that QuickJS-WASM plugin sandbox isolation in Instatic is robust against crashes, resource exhaustion, and security escapes.
Frequently Asked Questions
How does Instatic prevent a plugin from crashing the main server?
Instatic spawns each plugin in a Bun.Worker thread according to server/plugins/quickjs/vm.ts. Worker threads run in separate OS processes, so a segmentation fault, infinite loop, or memory exhaustion in one plugin terminates only that worker. The main Bun host process continues running and can detect the worker failure through its standard messaging protocol.
Can plugins access the filesystem or network directly?
No. Plugins run inside QuickJS-WASM, which has no bindings to the host filesystem, network sockets, or standard I/O. The WebAssembly sandbox provides a memory-safe execution environment with no direct system calls. Any file or network operations must go through __hostCall, where the host implementation enforces permission checks against the plugin's grantedPermissions array.
What happens when a plugin exceeds its memory or time limits?
The sandbox enforces hard caps defined in server/plugins/quickjs/limits.ts. Memory over-allocation triggers an OutOfMemory error inside the VM. Stack overflow from deep recursion hits the 1 MiB stack limit. Execution time exceeding the default 5-second deadline (customizable via DEFAULT_EVAL_TIMEOUT_MS) causes withSyncDeadline in server/plugins/quickjs/eval.ts to abort the VM and log the timeout. In all cases, the worker can be terminated without affecting other plugins or the host.
How does the host communicate with a plugin running in a synchronous VM?
Instatic uses a synchronous-initiated, async-resolved pattern. When plugin code calls __hostCall, the VM immediately returns a promise handle and yields. The host processes the request asynchronously, then resolves the promise from outside the VM. The runtime.executePendingJobs() call drains the micro-task queue to deliver the result. This design lets plugins write natural async/await code while the host maintains full control over when and how operations execute.
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 →