From dc9da1040c643b10584d3c55254794bfce62c13a Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Sun, 26 Jul 2026 21:28:46 +0000 Subject: [PATCH] feat: Add deny-by-default module allowlist Gate require behind an explicit allowedModules list on both the in-process and worker_threads tiers. Paths are always refused; bare and node: ids match. --- README.md | 29 +++++- src/index.ts | 8 ++ src/modules.ts | 112 ++++++++++++++++++++ src/sandbox.ts | 27 ++++- src/worker.ts | 25 ++++- test/modules.test.ts | 240 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 src/modules.ts create mode 100644 test/modules.test.ts diff --git a/README.md b/README.md index c7c03cc..2e89e41 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Ephemeral, zero-credential, self-verifying execution for untrusted or agent-writ An airlock is the safe way to run code you do not trust: an LLM-generated snippet, a plugin, a user-submitted function. This repo builds that primitive from the ground up in TypeScript. The guarantee is that a caller never reads an output unless the run stayed inside its resource ceilings and its output satisfies a post-condition the caller supplied. Untrusted code is guilty until proven correct, and the type system makes you prove it before you can touch the value. -The first slice was the contract and the in-process runner that enforces it. The second slice added `run(code, opts)`, which executes untrusted source in a fresh `node:vm` context with no ambient authority. The third slice added `runInWorker(code, opts)`: the same contract on a `worker_threads` isolate with frozen globals and an empty env. This slice hardens the **resource limit** layer: wall-clock deadline with hard terminate on abort, V8 heap cap, and output size caps, so a runaway cannot wedge the host on time, memory, or a multi-megabyte return value. Later slices add a Docker-backed tier and a growing suite of documented escape-attempt tests. +The first slice was the contract and the in-process runner that enforces it. The second slice added `run(code, opts)`, which executes untrusted source in a fresh `node:vm` context with no ambient authority. The third slice added `runInWorker(code, opts)`: the same contract on a `worker_threads` isolate with frozen globals and an empty env. The fourth slice hardens the **resource limit** layer: wall-clock deadline with hard terminate on abort, V8 heap cap, and output size caps. This slice adds a **deny-by-default module loader**: untrusted code has no `require` unless the caller opts in with `allowedModules`, and only the listed builtin or package ids resolve. Relative and absolute paths are always refused. Later slices add a Docker-backed tier and a growing suite of documented escape-attempt tests. ## Concepts demonstrated @@ -23,6 +23,9 @@ The first slice was the contract and the in-process runner that enforces it. The - **Thread-level isolation with `worker_threads`.** `runInWorker` runs untrusted source in a dedicated V8 isolate on its own thread. The in-process constructor-walk escape reaches only the worker's realm, which is started with an empty `process.env` and cannot see the host's environment. An escape-attempt test walks the same constructor chain and confirms a host secret placed in `process.env` stays out of reach. - **Frozen realm hardening.** Before any untrusted code runs, the worker freezes `globalThis` and the core intrinsics and their prototypes, so an escape into the worker realm cannot repave shared state that later runs in the same isolate would rely on. - **Layered preemption.** A synchronous spin is killed by V8's `timeout`; an async task that never settles is aborted by the deadline race (and terminated on the worker tier). `run` composes both so neither class of runaway can wedge the caller. +- **Deny-by-default module loading.** `require` is unbound unless the caller sets `allowedModules`. An empty list injects a gate that refuses every specifier; a non-empty list is an exact-match allowlist (with bare/`node:` equivalence), never a prefix grant. +- **Capability allowlists.** Module loading is treated as ambient authority: the host's real `require` is reachable only after the gate admits the id, so unlisted builtins like `fs` stay closed even when a sibling id is granted. +- **Path-specifier refusal.** Relative and absolute paths are dropped from the allowlist and rejected at load time so filesystem resolution cannot re-open host I/O through a crafty entry. - **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`. ## The primitive contract @@ -123,6 +126,29 @@ await runInWorker("'x'.repeat(1_000_000)", { // -> { status: "output-too-large", maxOutputBytes: 1024, actualBytes: ... } ``` +Module loading is off by default. Pass `allowedModules` to inject a gated `require` on either tier. Only exact builtin or package ids resolve; paths never do. + +```ts +import { run, isVerified } from "airlock"; + +// allowed: path (bare or node:path). denied: fs, relative paths, everything else. +const result = await run("require('node:path').join('a', 'b')", { + timeoutMs: 100, + assert: (p) => typeof p === "string", + allowedModules: ["path"], +}); + +if (isVerified(result)) console.log(result.value); + +// empty allowlist still injects require, but every load throws ModuleNotAllowedError +await run("require('path')", { + timeoutMs: 100, + assert: () => true, + allowedModules: [], +}); +// -> { status: "error", error: ModuleNotAllowedError } +``` + ## Develop ```bash @@ -138,3 +164,4 @@ pnpm run build - `src/sandbox.ts`: `run(code, opts)` executes untrusted source in a zero-credential `node:vm` context (no `process`/`require`/`fetch`/timers), grants only what the caller passes, preempts synchronous spins via V8's timeout, and fails closed with a probed `ZeroCredentialViolation` if ambient authority leaks in. Includes documented escape-attempt tests. - `src/worker.ts`: `runInWorker(code, opts)` runs untrusted source in a `worker_threads` isolate started with an empty `process.env` and frozen globals, caps the heap with `maxOldGenerationSizeMb` (reported as `out-of-memory`), and hard-kills the thread on the deadline so a sync spin and a never-settling async task are both preempted. An escape-attempt test confirms the constructor walk that reaches the host realm in-process reaches only the credential-free worker realm here. - `src/limits.ts`: shared resource ceilings for every tier. Wall-clock timeout aborts the task signal and, on the worker tier, calls `worker.terminate()` on both deadline and caller abort. Heap cap via V8 `resourceLimits`. Output size caps (`maxOutputBytes`) measure UTF-8 payload with a budgeted walk (cycle-safe, early-exit) and refuse with `output-too-large` before the post-condition runs. +- `src/modules.ts`: deny-by-default module loader with an explicit `allowedModules` allowlist. Omitted means no `require`; `[]` or a list injects `createGatedRequire` over the host/worker require. Exact match only (bare and `node:` equivalent), path specifiers always refused, and the gate wins over a grant-supplied `require`. Wired into both `run` and `runInWorker`. diff --git a/src/index.ts b/src/index.ts index 7d7589c..0b399c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,14 @@ export { export type { SandboxRunOptions } from "./sandbox.js"; export { runInWorker, freezeRealm, FROZEN_INTRINSICS } from "./worker.js"; export type { WorkerRunOptions } from "./worker.js"; +export { + ModuleNotAllowedError, + expandAllowlist, + isPathSpecifier, + isModuleAllowed, + createGatedRequire, + buildSandboxRequire, +} from "./modules.js"; export { validateResourceLimits, measureOutputBytes, diff --git a/src/modules.ts b/src/modules.ts new file mode 100644 index 0000000..6c3d995 --- /dev/null +++ b/src/modules.ts @@ -0,0 +1,112 @@ +import { createRequire } from "node:module"; + +export class ModuleNotAllowedError extends Error { + readonly specifier: string; + + constructor(specifier: string) { + super(`module not allowed: ${specifier}`); + this.name = "ModuleNotAllowedError"; + this.specifier = specifier; + } +} + +/** + * Allowlist stores both bare and node: forms so listing either is enough. + * Path-like specifiers are never admitted: allowlists are for package/builtin + * ids, not filesystem resolution that would re-open ambient I/O. + */ +export function expandAllowlist(modules: readonly string[]): ReadonlySet { + const allowed = new Set(); + for (const id of modules) { + if (typeof id !== "string" || id.length === 0) continue; + if (isPathSpecifier(id)) continue; + allowed.add(id); + if (id.startsWith("node:")) allowed.add(id.slice(5)); + else allowed.add(`node:${id}`); + } + return allowed; +} + +export function isPathSpecifier(id: string): boolean { + return ( + id.startsWith(".") || + id.startsWith("/") || + id.startsWith("\\") || + /^[A-Za-z]:[\\/]/.test(id) + ); +} + +export function isModuleAllowed( + specifier: string, + allowedModules: readonly string[], +): boolean { + if (typeof specifier !== "string" || specifier.length === 0) return false; + if (isPathSpecifier(specifier)) return false; + const allowed = expandAllowlist(allowedModules); + if (allowed.has(specifier)) return true; + const bare = specifier.startsWith("node:") ? specifier.slice(5) : specifier; + return allowed.has(bare) || allowed.has(`node:${bare}`); +} + +/** + * Self-contained so the worker bootstrap can inject it via Function#toString. + * hostRequire is the only capability that can actually load; this gate only + * decides whether that capability is invoked. + */ +export function createGatedRequire( + allowedModules: readonly string[], + hostRequire: (id: string) => unknown, +): (id: string) => unknown { + const allowed = new Set(); + for (const id of allowedModules) { + if (typeof id !== "string" || id.length === 0) continue; + if ( + id.startsWith(".") || + id.startsWith("/") || + id.startsWith("\\") || + /^[A-Za-z]:[\\/]/.test(id) + ) { + continue; + } + allowed.add(id); + if (id.startsWith("node:")) allowed.add(id.slice(5)); + else allowed.add(`node:${id}`); + } + + return function gatedRequire(id: string): unknown { + const deny = (specifier: string): never => { + // defineProperty (not assignment): freezeRealm freezes Error.prototype, so + // `err.name = ...` throws TypeError in the worker isolate. + const err = new Error(`module not allowed: ${specifier}`); + Object.defineProperty(err, "name", { + value: "ModuleNotAllowedError", + configurable: true, + }); + throw err; + }; + + if (typeof id !== "string" || id.length === 0) { + deny(String(id)); + } + if ( + id.startsWith(".") || + id.startsWith("/") || + id.startsWith("\\") || + /^[A-Za-z]:[\\/]/.test(id) + ) { + deny(id); + } + const bare = id.startsWith("node:") ? id.slice(5) : id; + if (!allowed.has(id) && !allowed.has(bare) && !allowed.has(`node:${bare}`)) { + deny(id); + } + return hostRequire(id); + }; +} + +export function buildSandboxRequire( + allowedModules: readonly string[], +): (id: string) => unknown { + const hostRequire = createRequire(import.meta.url); + return createGatedRequire(allowedModules, (id) => hostRequire(id)); +} diff --git a/src/sandbox.ts b/src/sandbox.ts index 9498e39..d2a54b8 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -1,5 +1,6 @@ import * as vm from "node:vm"; import type { Assertion, RunResult } from "./contract.js"; +import { buildSandboxRequire } from "./modules.js"; import { runVerified } from "./run.js"; /** @@ -32,6 +33,11 @@ export interface SandboxRunOptions { assert: Assertion; /** Capabilities the caller chooses to hand in. This is the only authority the code gets. */ grant?: Readonly>; + /** + * Builtin/package ids the sandbox may load via `require`. Omitted means no + * `require` at all; `[]` injects a require that denies every specifier. + */ + allowedModules?: readonly string[]; signal?: AbortSignal; filename?: string; maxOutputBytes?: number; @@ -93,10 +99,25 @@ export async function run( code: string, opts: SandboxRunOptions, ): Promise> { - const { timeoutMs, assert, grant, signal, filename, maxOutputBytes } = opts; + const { + timeoutMs, + assert, + grant, + allowedModules, + signal, + filename, + maxOutputBytes, + } = opts; - const context = vm.createContext({ ...(grant ?? {}) }); - const leaked = probeAmbientAuthority(context, Object.keys(grant ?? {})); + // allowedModules always wins over a grant-supplied require so a caller cannot + // accidentally re-open full host require while intending an allowlist. + const bindings: Record = { ...(grant ?? {}) }; + if (allowedModules !== undefined) { + bindings.require = buildSandboxRequire(allowedModules); + } + + const context = vm.createContext(bindings); + const leaked = probeAmbientAuthority(context, Object.keys(bindings)); if (leaked.length > 0) throw new ZeroCredentialViolation(leaked); let script: vm.Script; diff --git a/src/worker.ts b/src/worker.ts index 8d32465..ea8a109 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,7 @@ import { Worker } from "node:worker_threads"; import type { Assertion, RunResult } from "./contract.js"; import { checkOutputSize, validateResourceLimits } from "./limits.js"; +import { createGatedRequire } from "./modules.js"; /** * Intrinsics whose prototypes a sandbox escape could otherwise repave to attack @@ -53,6 +54,11 @@ export interface WorkerRunOptions { assert: Assertion; /** Structured-cloneable capabilities only; live functions can't cross the thread boundary. */ grant?: Readonly>; + /** + * Builtin/package ids the isolate may load via `require`. Omitted means no + * `require`; `[]` injects a require that denies every specifier. + */ + allowedModules?: readonly string[]; /** Hard cap on the isolate's V8 old-space. Exceeding it kills the worker. */ maxOldGenerationSizeMb?: number; /** Refuse values whose measured UTF-8 payload exceeds this many bytes. */ @@ -76,18 +82,28 @@ const OOM_CODE = "ERR_WORKER_OUT_OF_MEMORY"; // The worker body is a string so a single build artifact ships without a // separate worker entry file, and so tests exercise the same code as dist. -// freezeRealm is injected by source and applied to the worker's own globals. +// freezeRealm + createGatedRequire are injected by source and applied inside. const BOOTSTRAP = ` 'use strict'; const { workerData, parentPort } = require('node:worker_threads'); const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const path = require('node:path'); (${freezeRealm.toString()})(globalThis, ${JSON.stringify(FROZEN_INTRINSICS)}); +const createGatedRequire = ${createGatedRequire.toString()}; (async () => { try { - const { code, grant, timeoutMs, filename } = workerData; - const context = vm.createContext({ ...(grant || {}) }); + const { code, grant, timeoutMs, filename, allowedModules } = workerData; + const bindings = { ...(grant || {}) }; + // eval workers have no real __filename; anchor createRequire on cwd so + // builtins resolve and relative paths still hit the path-specifier deny. + if (Array.isArray(allowedModules)) { + const hostRequire = createRequire(path.join(process.cwd(), 'airlock-worker.js')); + bindings.require = createGatedRequire(allowedModules, hostRequire); + } + const context = vm.createContext(bindings); const script = new vm.Script(code, { filename }); const value = await script.runInContext(context, { timeout: timeoutMs }); parentPort.postMessage({ ok: true, value }); @@ -132,6 +148,7 @@ export function runInWorker( timeoutMs, assert, grant, + allowedModules, maxOldGenerationSizeMb, maxOutputBytes, signal, @@ -153,6 +170,8 @@ export function runInWorker( grant: grant ?? {}, timeoutMs, filename: filename ?? "airlock-worker.js", + allowedModules: + allowedModules === undefined ? undefined : [...allowedModules], }, ...(maxOldGenerationSizeMb !== undefined ? { resourceLimits: { maxOldGenerationSizeMb } } diff --git a/test/modules.test.ts b/test/modules.test.ts new file mode 100644 index 0000000..5a0b63b --- /dev/null +++ b/test/modules.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { + ModuleNotAllowedError, + createGatedRequire, + expandAllowlist, + isModuleAllowed, + isPathSpecifier, + isVerified, + run, + runInWorker, +} from "../src/index.js"; + +describe("isPathSpecifier / isModuleAllowed", () => { + it.each(["./x", "../x", "/abs", "\\win", "C:\\foo", "D:/bar"])( + "treats %s as a path specifier", + (id) => { + expect(isPathSpecifier(id)).toBe(true); + expect(isModuleAllowed(id, [id, "path"])).toBe(false); + }, + ); + + it("denies everything on an empty allowlist", () => { + expect(isModuleAllowed("path", [])).toBe(false); + expect(isModuleAllowed("node:path", [])).toBe(false); + }); + + it("accepts bare and node: forms interchangeably", () => { + expect(isModuleAllowed("path", ["path"])).toBe(true); + expect(isModuleAllowed("node:path", ["path"])).toBe(true); + expect(isModuleAllowed("path", ["node:path"])).toBe(true); + expect(isModuleAllowed("node:fs/promises", ["fs/promises"])).toBe(true); + }); + + it("does not grant a sibling or parent id by prefix", () => { + expect(isModuleAllowed("fs/promises", ["fs"])).toBe(false); + expect(isModuleAllowed("fs", ["fs/promises"])).toBe(false); + expect(isModuleAllowed("crypto", ["path"])).toBe(false); + }); + + it("rejects empty and non-string-like ids", () => { + expect(isModuleAllowed("", ["path"])).toBe(false); + }); + + it("drops path-like entries from the expanded allowlist", () => { + const allowed = expandAllowlist(["path", "./evil", ""]); + expect(allowed.has("path")).toBe(true); + expect(allowed.has("node:path")).toBe(true); + expect(allowed.has("./evil")).toBe(false); + }); +}); + +describe("createGatedRequire", () => { + it("loads an allowed builtin and throws ModuleNotAllowedError otherwise", () => { + const loads: string[] = []; + const gated = createGatedRequire(["path"], (id) => { + loads.push(id); + return { loaded: id }; + }); + + expect(gated("path")).toEqual({ loaded: "path" }); + expect(gated("node:path")).toEqual({ loaded: "node:path" }); + expect(loads).toEqual(["path", "node:path"]); + + try { + gated("fs"); + expect.unreachable(); + } catch (error) { + expect(error).toMatchObject({ + name: "ModuleNotAllowedError", + message: "module not allowed: fs", + }); + } + + expect(() => gated("./secret")).toThrowError(/module not allowed/); + expect(loads).toEqual(["path", "node:path"]); + }); + + it("never calls hostRequire for a denied id", () => { + let called = 0; + const gated = createGatedRequire([], () => { + called += 1; + return null; + }); + expect(() => gated("path")).toThrow(); + expect(called).toBe(0); + }); +}); + +describe("run with allowedModules", () => { + it("has no require when allowedModules is omitted", async () => { + const result = await run("typeof require", { + timeoutMs: 100, + assert: (v) => v === "undefined", + }); + expect(result).toMatchObject({ status: "ok", value: "undefined" }); + }); + + it("denies every load when the allowlist is empty", async () => { + const result = await run("require('path')", { + timeoutMs: 100, + assert: () => true, + allowedModules: [], + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect((result.error as Error).name).toBe("ModuleNotAllowedError"); + expect((result.error as Error).message).toContain("path"); + } + }); + + it("loads an allowed builtin and verifies the result", async () => { + const result = await run( + "require('node:path').join('a', 'b')", + { + timeoutMs: 100, + assert: (v) => v === "a/b" || v === "a\\b", + allowedModules: ["path"], + }, + ); + + expect(result.status).toBe("ok"); + if (isVerified(result)) { + expect(result.value === "a/b" || result.value === "a\\b").toBe(true); + } + }); + + it("refuses a module outside the allowlist", async () => { + const result = await run("require('fs')", { + timeoutMs: 100, + assert: () => true, + allowedModules: ["path"], + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect((result.error as Error).name).toBe("ModuleNotAllowedError"); + } + }); + + it("always denies relative and absolute path requires", async () => { + const relative = await run("require('./package.json')", { + timeoutMs: 100, + assert: () => true, + allowedModules: ["./package.json", "path"], + }); + expect(relative.status).toBe("error"); + if (relative.status === "error") { + expect((relative.error as Error).name).toBe("ModuleNotAllowedError"); + } + + const absolute = await run("require('/etc/passwd')", { + timeoutMs: 100, + assert: () => true, + allowedModules: ["/etc/passwd"], + }); + expect(absolute.status).toBe("error"); + }); + + it("overrides a grant-supplied require with the allowlist gate", async () => { + let fullRequireCalled = 0; + const result = await run("require('fs')", { + timeoutMs: 100, + assert: () => true, + allowedModules: ["path"], + grant: { + require: () => { + fullRequireCalled += 1; + return {}; + }, + }, + }); + + expect(result.status).toBe("error"); + expect(fullRequireCalled).toBe(0); + }); + + it("does not grant sibling subpaths by prefix", async () => { + const result = await run("require('fs/promises')", { + timeoutMs: 100, + assert: () => true, + allowedModules: ["fs"], + }); + expect(result.status).toBe("error"); + }); +}); + +describe("runInWorker with allowedModules", () => { + it("has no require when allowedModules is omitted", async () => { + const result = await runInWorker("typeof require", { + timeoutMs: 1000, + assert: (v) => v === "undefined", + }); + expect(result).toMatchObject({ status: "ok", value: "undefined" }); + }); + + it("loads an allowed builtin inside the isolate", async () => { + const result = await runInWorker( + "require('node:path').posix.join('a','b') === 'a/b'", + { + timeoutMs: 1000, + assert: (v) => v === true, + allowedModules: ["node:path"], + }, + ); + expect(result).toMatchObject({ status: "ok", value: true }); + }); + + it("refuses a denied module inside the isolate", async () => { + const result = await runInWorker("require('fs')", { + timeoutMs: 1000, + assert: () => true, + allowedModules: ["path"], + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect((result.error as Error).name).toBe("ModuleNotAllowedError"); + } + }); + + it("denies path-like requires even when listed", async () => { + const result = await runInWorker("require('../src/index.js')", { + timeoutMs: 1000, + assert: () => true, + allowedModules: ["../src/index.js", "path"], + }); + expect(result.status).toBe("error"); + }); +}); + +describe("ModuleNotAllowedError", () => { + it("carries the denied specifier", () => { + const err = new ModuleNotAllowedError("crypto"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("ModuleNotAllowedError"); + expect(err.specifier).toBe("crypto"); + expect(err.message).toBe("module not allowed: crypto"); + }); +});