Type‑Safe Secure Sandbox Execution in JavaScript/TypeScript: VM2, Realms & Compile‑Time Guarantees
Introduction
Running untrusted code is a classic problem for SaaS platforms, IDE extensions, or any system that lets users upload scripts.
JavaScript’s dynamism makes it easy to slip a malicious payload into a shared process, while Node’s single‑threaded event loop means a single rogue script can stall the whole service.
Modern TypeScript tooling gives us compile‑time guarantees about the shape of data, but it does not protect us from code that is executed outside our control.
In this article we combine three orthogonal techniques to build a type‑safe secure sandbox:
- VM2 – a battle‑tested, Node‑level sandbox that isolates
require,process, and the event loop. - Realms (ECMAScript Realm proposal /
realm-shim) – a pure‑JS abstraction that gives each sandbox its own global objects and intrinsics. - Static typing + runtime validation – Zod or
io-tsschemas that enforce the contract between host and sandbox at compile time and at the boundary.
By the end you will have a reusable library (sandbox.ts) that:
- Executes user scripts in a separate V8 context.
- Guarantees that only the declared API surface is accessible.
- Provides a typed wrapper so the host code can invoke sandbox functions without
any. - Is fully testable with Jest (or Vitest) and can be fuzz‑tested automatically.
Note – The article assumes Node ≥ 18 (for built‑in vm module) and TypeScript 5.x.1. Why Runtime isolation alone isn’t enough
A naïve sandbox might look like this:
import { NodeVM } from 'vm2';
const vm = new NodeVM({
sandbox: { console },
require: false,
});
const result = vm.run(`module.exports = 2 + 2;`);
The NodeVM guarantees that the script cannot require('fs') or access process.
However, type safety is lost:
// The result is typed as `any`
const answer: number = result; // <- no compile‑time error
If the user script returns a complex object, the host code has no guarantee about its shape.
A malicious script could also leak references to native prototypes (Object.prototype.__proto__ = ...) and break the isolation guarantees.
Thus we need a contract that is expressed in TypeScript and enforced both at compile time and at runtime.
2. Defining the Public API with Generics
Suppose we want to expose a tiny math library to user scripts:
// api.ts
export interface MathAPI {
add(a: number, b: number): number;
mul(a: number, b: number): number;
}
We will create a typed wrapper that marshals calls from the host to the sandbox and validates the payloads with Zod.
// validation.ts
import { z } from 'zod';
export const addSchema = z.object({
a: z.number(),
b: z.number(),
});
export const mulSchema = z.object({
a: z.number(),
b: z.number(),
});
The wrapper type:
// sandbox.ts
import { NodeVM, VMScript } from 'vm2';
import { MathAPI } from './api';
import { addSchema, mulSchema } from './validation';
import { ZodError } from 'zod';
type Guard<T> = (payload: unknown) => T;
export class TypedSandbox implements MathAPI {
private vm: NodeVM;
constructor() {
this.vm = new NodeVM({
console: 'inherit',
sandbox: {},
require: false,
wrapper: 'commonjs',
});
// Load a tiny runtime that registers the API implementation
const runtime = new VMScript(`
const api = {
add: ({a, b}) => a + b,
mul: ({a, b}) => a * b,
};
module.exports = api;
`);
this.vm.run(runtime);
}
private call<T>(fn: string, guard: Guard<T>, payload: unknown): T {
// Runtime validation
const fnInVm = this.vm.run(`module.exports.${fn}`);
try {
const result = fnInVm(payload);
return guard(result);
} catch (e) {
if (e instanceof ZodError) {
throw new Error(`Sandbox validation failed: ${e.message}`);
}
throw e;
}
}
// --- MathAPI implementation ---------------------------------
add(a: number, b: number): number {
const guard = (v: unknown) => {
if (typeof v !== 'number') throw new Error('Expected number');
return v;
};
return this.call('add', guard, { a, b });
}
mul(a: number, b: number): number {
const guard = (v: unknown) => {
if (typeof v !== 'number') throw new Error('Expected number');
return v;
};
return this.call('mul', guard, { a, b });
}
}
What we gained
| Concern | Before | After |
|---|---|---|
Type of result |
any |
number (inferred) |
| Payload shape | unchecked | addSchema.parse(payload) (runtime) |
| API surface leakage | possible via global |
only add/mul exported |
| Compile‑time contract | none | TypedSandbox implements MathAPI |
3. Using Realms for a Pure‑JS sandbox (optional but valuable)
vm2 runs code in a separate V8 context, which is great for Node, but it does not work in the browser.
The ECMAScript Realm proposal (still stage 3) allows us to create a fresh global object with its own intrinsics. A lightweight shim (realm-shim) works in both Node and browsers.
// realmSandbox.ts
import { createRealm } from 'realm-shim';
import { z } from 'zod';
import { MathAPI } from './api';
export class RealmSandbox implements MathAPI {
private realm: ReturnType<typeof createRealm>;
constructor() {
this.realm = createRealm({
// The sandbox can only see what we explicitly expose.
console,
});
// Define the API inside the realm.
this.realm.eval(`
const api = {
add({a, b}) { return a + b; },
mul({a, b}) { return a * b; },
};
globalThis.__api = api;
`);
}
private invoke<T>(name: keyof MathAPI, schema: z.Schema<any>, payload: any, guard: (v: unknown) => T): T {
const parsed = schema.parse(payload);
const result = (this.realm.globalThis as any).__api[name](parsed);
return guard(result);
}
add(a: number, b: number): number {
return this.invoke('add', z.object({ a: z.number(), b: z.number() }), { a, b }, v => {
if (typeof v !== 'number') throw new Error('bad result');
return v;
});
}
mul(a: number, b: number): number {
return this.invoke('mul', z.object({ a: z.number(), b: z.number() }), { a, b }, v => {
if (typeof v !== 'number') throw new Error('bad result');
return v;
});
}
}
Why combine both?
- VM2 provides true OS‑level isolation (no access to native code, memory limits, etc.).
- Realms give us a portable sandbox that works in the browser, useful for client‑side plugins or progressive‑web‑app extensions.
A hybrid approach creates a TypedSandbox that internally decides which engine to use based on the environment.
4. Testing the Sandbox
4.1 Unit tests with Jest
// sandbox.test.ts
import { TypedSandbox } from './sandbox';
describe('TypedSandbox', () => {
const sandbox = new TypedSandbox();
it('adds numbers', () => {
expect(sandbox.add(2, 3)).toBe(5);
});
it('rejects non‑numeric payloads', () => {
// @ts-expect-error – intentional misuse
expect(() => sandbox.add('a' as any, 1)).toThrow();
});
it('prevents access to global process', () => {
const malicious = `
module.exports = {
add: () => process.exit(0)
};
`;
// Load malicious script into a fresh VM and expect it to throw
expect(() => sandbox['vm'].run(malicious)).toThrow();
});
});
4.2 Property‑based fuzzing
Using fast-check we can generate random numeric inputs and assert algebraic properties:
import fc from 'fast-check';
import { TypedSandbox } from './sandbox';
test('addition is commutative', () => {
const sb = new TypedSandbox();
fc.assert(
fc.property(fc.float(), fc.float(), (a, b) => {
return sb.add(a, b) === sb.add(b, a);
})
);
});
Fuzz tests run inside the host process, but the sandboxed code is still exercised, giving confidence that the isolation layer does not corrupt the host.
5. Adding Resource Limits
VM2 lets us cap CPU time and memory:
this.vm = new NodeVM({
console: 'inherit',
sandbox: {},
require: false,
wrapper: 'commonjs',
timeout: 2000, // 2 s max per script
eval: false,
wasm: false,
sandbox: {},
// Memory limit in MB (requires `--max-old-space-size` flag)
// Note: VM2 does not enforce memory directly; we rely on V8's `--max-old-space-size`.
});
For Realms we can simulate a timeout using Promise.race:
private async safeCall<T>(fn: () => T, ms: number): Promise<T> {
return Promise.race([
Promise.resolve().then(fn),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms)),
]);
}
Combine both strategies for a defense‑in‑depth sandbox.
6. Deploying the Sandbox in a Real‑World Service
Imagine a SaaS that lets users write “pricing rules” in JavaScript:
// user‑script.js (uploaded by a tenant)
module.exports = {
compute: ({ base, usage }) => {
if (usage > 1000) return base * 0.9;
return base;
},
};
We can load this script with our TypedSandbox:
import { TypedSandbox } from './sandbox';
import fs from 'fs';
async function loadRule(path: string) {
const code = await fs.promises.readFile(path, 'utf8');
const vm = new NodeVM({
console: 'off',
sandbox: {},
require: false,
wrapper: 'commonjs',
timeout: 1000,
});
const rule = vm.run(code);
// Validate shape with Zod
const ruleSchema = z.object({
compute: z.function(z.tuple([z.object({ base: z.number(), usage: z.number() })]), z.number()),
});
return ruleSchema.parse(rule);
}
// Example usage
(async () => {
const rule = await loadRule('./user-script.js');
const price = rule.compute({ base: 100, usage: 1500 });
console.log('Discounted price:', price);
})();
The host never trusts the script directly; it only accepts a function that matches the declared schema.
If a malicious actor tries to require('child_process'), the VM throws before the schema validation runs.
7. Limitations & Gotchas
| Issue | Explanation | Mitigation |
|---|---|---|
| Side‑channel timing attacks | Even without process, a script can infer host load via Date.now(). |
Add deterministic clocks or limit Date.now() inside the sandbox. |
| Prototype pollution | Object.prototype.__proto__ = malicious can affect the host if references escape. |
Deep‑clone results (structuredClone) before returning to host. |
| Memory pressure | VM2 does not enforce a hard memory cap; a script can allocate large arrays. | Run each sandbox in a separate OS process (worker_threads) and monitor RSS. |
| Async APIs | VM2’s sandbox is synchronous by default. | Expose a limited async bridge (e.g., a fetch wrapper) that validates responses. |
| Realm support | Still a proposal; polyfills may lag behind V8 updates. | Use Realms only for browser environments where Node’s VM is unavailable. |
8. Putting It All Together – A Minimal Library
// src/index.ts
export { TypedSandbox } from './sandbox';
export { RealmSandbox } from './realmSandbox';
export type { MathAPI } from './api';
// usage.ts
import { TypedSandbox } from './src';
const sandbox = new TypedSandbox();
console.log('2 + 3 =', sandbox.add(2, 3));
console.log('4 × 5 =', sandbox.mul(4, 5));
The public surface is only the MathAPI interface; anything else is hidden behind the sandbox boundary.
Because the interface lives in a type‑only module, the compiled JavaScript contains no runtime checks for the host side. All safety comes from the runtime validation performed inside the sandbox and from the compile‑time contract enforced by TypeScript.
Conclusion
Secure sandboxing in JavaScript is no longer an after‑thought. By:
- Isolating execution with VM2 (or Realms for the browser),
- Describing the allowed contract with TypeScript interfaces,
- Validating every inbound/outbound payload with a schema library,
- Testing both the host wrapper and the sandboxed code with unit and property‑based tests,
we obtain a type‑safe, auditable, and portable sandbox that can be dropped into any Node or browser‑based platform.
The pattern scales: replace MathAPI with a richer domain‑specific API, generate Zod schemas automatically from TypeScript types using ts-to-zod, and you have a production‑ready plugin system that developers can trust – and that your security team can approve.
Happy sandboxing!
Member discussion