Instatic Plugin Security Model and QuickJS-WASM Sandbox: A Deep Dive

Instatic isolates every third-party plugin inside a hardened QuickJS-WASM sandbox that blocks Node.js and Bun APIs, enforces capability-based permissions, and prevents raw bytes and secrets from crossing the security boundary.

The Instatic platform, maintained in the CoreBunch/Instatic repository, implements a capability-based security model to safely execute untrusted third-party code. By leveraging a WebAssembly-compiled QuickJS engine, the system creates a strict isolation boundary between the host runtime and plugin logic. This architecture ensures that extensions operate with the minimum necessary privileges while protecting user data and host resources from arbitrary code execution.

QuickJS-WASM Sandbox Architecture

Isolated Execution Context

The sandbox instantiation begins in server/plugins/quickjs/vm.ts, which spins up a single-instance QuickJS WASM module and creates a fresh QuickJSContext for each plugin. This context has no access to the host process except for explicitly registered polyfills and bridge functions. Each plugin receives its own isolated VM instance, preventing cross-plugin memory access or state leakage.

The factory function in vm.ts wires host-side bridges to the sandbox only after validating the plugin's declared permissions, ensuring that unauthorized capabilities remain unreachable from within the QuickJS context.

Static Analysis and Forbidden API Detection

Before any code enters the sandbox, src/core/plugins/sandboxScan.ts performs static analysis on plugin bundles to detect forbidden literals. The scanner blocks any usage of require(), node:fs, Bun.spawn, or other Node.js/Bun-specific APIs. If the build process detects these patterns, the plugin fails validation and cannot be published or loaded.

This static analysis operates as the first line of defense, catching privilege escalation attempts before runtime execution.

Capability-Based Permission System

Permission Declarations in Manifests

Plugins declare their required capabilities in plugin.json, which src/core/plugins/manifest.ts validates against the schema defined in src/core/plugin-sdk/types/permissions.ts. Each permission maps to a concrete host bridge:

  • network.outbound → Exposes the sandboxed fetch() polyfill
  • cms.routes.public → Allows registration of public HTTP endpoints
  • cms.media.upload → Grants access to media storage adapters

The host validates these declarations during sandbox initialization. A plugin cannot access any host functionality it does not explicitly request and the user does not approve.

Network Access Controls

Outbound HTTP requests are strictly controlled through the networkAllowedHosts array in the plugin manifest. When a plugin invokes the sandboxed fetch() implementation (provided via src/core/plugin-sdk/types/serverApi.ts), the host bridge checks the target hostname against this whitelist. Requests to non-whitelisted hosts are rejected at the bridge level, preventing data exfiltration to unauthorized endpoints.

Resource Constraints and Execution Limits

server/plugins/quickjs/limits.ts imposes hard resource ceilings on every VM instance. Each plugin operates within a confined memory budget and execution-time limit that prevents runaway code from consuming host resources. These constraints apply to all sandboxed entry points, including cron-style loops defined via src/core/plugin-sdk/capabilities.ts.

If a plugin exceeds its allocated CPU time or memory allocation, the QuickJS runtime terminates the context immediately, isolating the failure to the specific plugin without affecting the host process or other extensions.

Data Flow Security Boundaries

Media Storage Isolation

The platform enforces a strict "bytes never cross the sandbox" policy, as documented in mediaStorageRegistry.ts. When handling file uploads or storage operations, the sandboxed plugin receives only signed URLs or metadata, never raw byte streams. The host process manages all binary data transfer directly with storage backends, while the plugin manipulates only references and configuration objects.

This design prevents malicious plugins from intercepting or exfiltrating user media data during upload or download operations.

Deterministic Bridge Communication

All communication between the host and VM traverses server/plugins/quickjs/marshal.ts, which enforces JSON-serializable data constraints. The marshaller rejects exotic objects, functions, or circular references, ensuring that only pure data crosses the security boundary. This prevents prototype pollution attacks and maintains deterministic behavior across the bridge.

Secret Management and Injection

Sensitive credentials never enter the sandbox. server/repositories/pluginSecrets.ts manages secret storage and injection on the host side exclusively. When a plugin requires a secret value (such as an API key), the host resolves the reference and injects the value into the request at the bridge level. The sandbox receives only the final computed result, maintaining a "never leak secrets" invariant that protects against memory scraping and logging attacks.

Unsandboxed Entry Points

While server-side plugin code runs exclusively within the QuickJS-WASM sandbox, src/core/plugins/manifest.ts recognizes two exceptions: entrypoints.editor and entrypoints.app. These entry points run unsandboxed within the admin window context, possessing full browser privileges. The manifest validator enforces strict trust requirements on plugins declaring these entry points, as they bypass the WASM isolation boundary.

Practical Implementation Examples

Declaring Sandbox Permissions

{
  "id": "my.sample",
  "version": "0.1.0",
  "entrypoints": {
    "server": "./dist/server/index.js"
  },
  "permissions": [
    "network.outbound",
    "cms.routes.public",
    "cms.media.upload"
  ],
  "networkAllowedHosts": ["api.example.com"]
}

The permissions array drives the host-side bridge initialization in src/core/plugin-sdk/types/permissions.ts, determining which capabilities the sandbox can access.

Using the Sandboxed Fetch API

import { fetch } from '@core/plugin-sdk';

export async function handler() {
  // Executes within QuickJS sandbox, respecting networkAllowedHosts whitelist
  const res = await fetch('https://api.example.com/data');
  return res.json();
}

Registering a Media Adapter

import { registerStorageAdapter } from '@core/plugin-sdk/media';

registerStorageAdapter({
  id: 's3',
  servingMode: 'signedUrl',
  getUploadPlan: async (path) => ({
    url: `https://s3.amazonaws.com/${path}?signature=...`,
    method: 'PUT',
    headers: { 'Content-Type': 'application/octet-stream' }
  })
});

The adapter executes on the host side; the sandbox receives only the signed URL configuration, never the raw bytes.

Defining a Resource-Limited Cron Loop

import { defineLoop } from '@core/plugin-sdk/capabilities';

defineLoop({
  schedule: '0 */5 * * *',
  handler: async () => {
    // Runs inside QuickJS VM with memory/CPU limits from limits.ts
    await performPeriodicTask();
  }
});

Summary

Frequently Asked Questions

How does Instatic prevent plugins from accessing the filesystem directly?

The platform employs a two-layer defense. First, src/core/plugins/sandboxScan.ts statically analyzes bundles for forbidden patterns including node:fs, fs/promises, or require() calls. Second, the QuickJS-WASM runtime in server/plugins/quickjs/vm.ts executes without host bindings to filesystem APIs, ensuring that even if static analysis misses an edge case, the runtime environment lacks the capability to perform file operations.

What happens when a plugin exceeds its memory or CPU limits?

server/plugins/quickjs/limits.ts configures hard ceilings for each VM instance. If a plugin consumes its entire memory allocation or exceeds the execution-time budget (such as an infinite loop in a cron handler), the QuickJS runtime immediately terminates that specific context. The host process logs the violation and disables the offending plugin without affecting other extensions or the core platform.

Can sandboxed plugins make HTTP requests to external APIs?

Yes, but only through controlled channels. Plugins must declare the network.outbound permission and list specific hostnames in networkAllowedHosts. The fetch() implementation exposed to the sandbox validates every request URL against this whitelist at the host bridge level. Requests to non-whitelisted domains are rejected before leaving the host environment, preventing unauthorized data exfiltration.

Are plugin entry points ever allowed to run outside the sandbox?

Only entrypoints.editor and entrypoints.app run unsandboxed, executing within the admin window's browser context with full privileges. According to src/core/plugins/manifest.ts, these entry points undergo stricter trust validation during manifest parsing. All entrypoints.server code executes exclusively within the QuickJS-WASM sandbox, regardless of the plugin's trust status.

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 →