How Are Plugins Sandboxed in Instatic? QuickJS-WASM Isolation and Permission Bridges Explained
Instatic isolates every plugin inside a QuickJS-WASM runtime that deliberately exposes no Node or Bun APIs, enforcing security through build-time static analysis, runtime memory isolation, and a permission-governed SDK bridge.
The CoreBunch/Instatic repository implements a defense-in-depth strategy to ensure untrusted plugin code cannot access the host server. Every plugin’s server entrypoint and module pack execute inside a WebAssembly-based JavaScript interpreter, with strict controls that prevent filesystem access, process spawning, or unauthorized network requests.
The Three-Layer Sandbox Architecture
Instatic enforces plugin isolation through three distinct security layers that operate at different stages of the plugin lifecycle.
Build-Time Static Analysis
Before a plugin ever reaches a live server, the CLI scans bundled source code for forbidden literals that would indicate attempts to escape the sandbox. This validation lives in src/core/plugins/sandboxScan.ts and runs during both local builds (instatic-plugin build) and server-side uploads.
The scanner rejects any bundle containing references to Node or Bun internals:
// src/core/plugins/sandboxScan.ts
const FORBIDDEN_SANDBOX_LITERALS = [
"'node:",
'"node:',
"'bun:",
'"bun:',
'require(',
'process.binding',
'globalThis.process.env',
] as const;
export function assertSandboxSafe(source: string, sourceLabel: string): void {
const findings = findSandboxLiterals(source);
if (findings.length) {
const offenders = findings.map(f => f.literal).join(', ');
throw new Error(
`Plugin sandbox: bundle for "${sourceLabel}" references forbidden literals: ${offenders}.\n` +
`Plugins run inside a QuickJS-WASM sandbox with no access to Node/Bun runtime APIs.`
);
}
}
If a developer accidentally includes import 'node:fs' or process.binding, the build aborts immediately with a descriptive error, preventing unsafe code from reaching the runtime environment.
Runtime QuickJS VM Isolation
When the server activates a plugin, it creates a dedicated QuickJS context that operates as a true memory sandbox. The SandboxesModulePack interface in src/core/plugins/modulePackLoader.ts defines the contract for these isolated containers:
// src/core/plugins/modulePackLoader.ts
export interface SandboxedModulePack {
readonly pluginId: string;
render(moduleId:string, props:Record<string,unknown>, children:string[]): {html:string; css?:string; js?:string};
preview(...): any;
dispose(): void; // frees the QuickJS native context
}
The activateSandboxedPluginModulePack function instantiates the VM only after verifying the plugin manifest includes the modules.register permission. Each render call executes inside the WebAssembly boundary, ensuring that crashes, infinite loops, or memory exhaustion within the plugin cannot affect the host Node.js or Bun process.
Permission-Driven SDK Bridges
While the QuickJS sandbox blocks direct system access, plugins still require limited capabilities like HTTP requests or data storage. Instatic exposes these through a strictly controlled SDK bridge defined in src/core/plugin-sdk/types/permissions.ts.
For example, outbound network access requires explicit manifest declarations:
// src/core/plugin-sdk/types/permissions.ts
// NetworkPermission is granted only when the manifest lists networkAllowedHosts
// and the bridge validates the URL against that allow-list.
When sandboxed code invokes api.cms.fetch(url, opts), the bridge performs two validation checks:
- The plugin manifest includes
networkingrantedPermissions. - The target host matches an entry in
networkAllowedHosts.
If either check fails, the bridge throws a sandbox-level Error that the plugin may catch but cannot bypass. This capability-based security model ensures plugins access only the specific resources declared in their manifest.
Build-Time Scanning: Blocking Unsafe Code Before It Runs
The assertSandboxSafe function serves as the first line of defense against supply-chain attacks and developer errors. By scanning for literal strings like require(, node:, and bun:, the scanner detects attempts to import native modules before the code ships.
This static analysis runs in two contexts:
- Development: The CLI plugin build command fails fast when detecting forbidden patterns.
- Production: The server re-scans uploaded ZIP packages during installation, providing defense in depth against tampered bundles.
Runtime Isolation: QuickJS-WASM Memory Safety
Unlike Node.js VM contexts or worker threads, the QuickJS-WASM sandbox shares no memory space with the host process. The SandboxesModulePack created by modulePackLoader.ts wraps the QuickJS interpreter in a WebAssembly boundary that implements only a subset of standard Web Request/Response APIs required for plugin I/O.
Key safety characteristics of this architecture:
- Memory isolation: The WASM heap is disjoint from the host's JavaScript heap, preventing prototype pollution or buffer overflow attacks from escaping.
- API restriction: The global scope within the sandbox contains no
process,Buffer,require, or filesystem utilities. - Resource cleanup: The
dispose()method frees the native QuickJS context when a plugin deactivates or upgrades, preventing memory leaks and stale state accumulation.
Permission Governance: Controlled Host Access
The bridge architecture in src/core/plugins/runtime.ts exposes host capabilities through namespaced APIs like api.store, api.dashboardWidget, and api.cms.routes. Each API group checks against the permission manifest before executing host-side code.
The network permission demonstrates this pattern:
- Declaration: The plugin manifest must list
networkingrantedPermissionsand specify allowed hosts innetworkAllowedHosts. - Validation: The bridge validates URLs against the allow-list before initiating the fetch.
- Enforcement: Attempts to contact non-approved hosts throw immediate errors visible only within the sandbox.
This model extends to other sensitive operations, ensuring plugins follow the principle of least privilege by default.
Sandbox Lifecycle: From Build to Teardown
Instatic applies sandboxing consistently across the entire plugin lifecycle:
- Authoring: Developers write standard JavaScript/TypeScript without special syntax constraints.
- Build: The bundler produces the entrypoint, then
assertSandboxSafevalidates the output against the forbidden literals list. - Upload: The server repeats the static analysis when accepting the plugin package.
- Activation: The host creates a fresh QuickJS-WASM VM via
activateSandboxedPluginModulePack, registering module metadata while keeping execution inside the sandbox. - Execution: All
rendercalls execute within the VM; only approved SDK functions bridge to the host. - Teardown:
dispose()frees native resources when the plugin disables or the server shuts down.
Summary
- QuickJS-WASM isolation ensures plugins run in a memory-safe WebAssembly interpreter with no access to Node/Bun APIs, as implemented in
src/core/plugins/modulePackLoader.ts. - Build-time scanning via
src/core/plugins/sandboxScan.tsblocks forbidden literals likenode:imports andprocess.bindingbefore code ever reaches a server. - Permission bridges defined in
src/core/plugin-sdk/types/permissions.tsgrant capabilities like network access only when explicitly declared in the plugin manifest and validated against allow-lists. - Automatic cleanup through the
dispose()method onSandboxedModulePackprevents resource leaks when plugins deactivate or upgrade.
Frequently Asked Questions
Can plugins access the filesystem or spawn processes?
No. The QuickJS-WASM sandbox in Instatic exposes no filesystem or process APIs. According to the source code in src/core/plugins/sandboxScan.ts, any attempt to include require(, node:fs, bun:, or process.binding triggers a build failure. Runtime execution occurs entirely within a WebAssembly boundary that lacks these capabilities.
What happens if a plugin crashes or enters an infinite loop?
The isolated QuickJS context contains crashes within the WebAssembly sandbox. Because the VM shares no memory with the host process, exceptions thrown inside render calls bubble up only to the bridge layer, which logs the error without affecting other plugins or the core server. The host can terminate individual plugin VMs via the dispose() method without restarting the entire application.
How does the network permission system work?
Network access requires explicit opt-in through the plugin manifest. As defined in src/core/plugin-sdk/types/permissions.ts, the plugin must declare network in grantedPermissions and list approved domains in networkAllowedHosts. When sandboxed code calls api.cms.fetch(), the bridge validates the URL against this allow-list before executing the request, rejecting any attempts to contact unauthorized hosts.
Is the sandbox vulnerability-free against malicious code?
While no security system is absolute, Instatic's three-layer approach—static analysis, WASM memory isolation, and capability-based permissions—significantly reduces the attack surface. The QuickJS interpreter provides a formally smaller trusted computing base than a full Node.js VM, and the removal of Node-specific globals prevents common escape techniques. However, administrators should still only install plugins from trusted sources and review requested permissions carefully.
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 →