@johnhenry/andbox 0.0.10 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -257,6 +257,8 @@ Creates a new sandboxed runtime. Returns a promise (Worker mode) or object (inli
257
257
  | `capabilities` | `Record<string, Function>` | `{}` | Host functions callable via `host.call()` (Worker mode) |
258
258
  | `defaultTimeoutMs` | `number` | `30000` | Default timeout for `evaluate()` |
259
259
  | `baseURL` | `string` | `location.href` | Base URL for relative imports |
260
+ | `allowedImportHosts` | `string[]` | unset | Restricts `sandboxImport()` of remote `http(s)` modules to these hostnames (plus `baseURL`'s own host). **Unset: remote imports are allowed.** Provided: only listed hosts; `[]` denies all remote imports. Import-map targets are host-authored and always allowed. |
261
+ | `untrusted` | `boolean` | `false` | Convenience for untrusted code: selects `mode: 'wasm'`. Throws if combined with another `mode`, and rejects (never falls back to a Worker) if wasm mode is unavailable. See [Security model](#security-model). |
260
262
  | `policy` | `GatePolicy` | -- | Rate limiting policy |
261
263
  | `onConsole` | `(level, ...args) => void` | -- | Console output handler |
262
264
  | `globals` | `Record<string, any>` | `{}` | Global variables (inline/data-uri modes) |
@@ -421,6 +423,11 @@ Code runs inside a Web Worker created from a Blob URL. This gets you, for free,
421
423
 
422
424
  andbox is **not** a boundary against code that is actively trying to escape it. If you're running code you don't fully trust, read this section before you rely on `capabilities`/`policy`/`createNetworkFetch` for anything.
423
425
 
426
+ **Pick the mode by how much you trust the code:**
427
+
428
+ - **`worker` and `node-worker` are for trusted or semi-trusted code** (your own scripts, plugins from known authors, LLM output you review). They organise and throttle what the code does; they do not contain a determined attacker. Still reachable from code in these modes: the platform `import()` operator (fetches and runs remote code, an exfiltration channel; `allowedImportHosts` only governs `sandboxImport()`), timing and `SharedArrayBuffer`/`Atomics` side channels, the Worker's shared realm and heap, any global a future platform adds that is not on the deny-list, and under Node `process`, `require` and the rest of the Node API.
429
+ - **`wasm` is the mode for untrusted code**, and `createSandbox({ untrusted: true })` selects it (and throws or rejects instead of falling back if it is unavailable, or if combined with another `mode`). The code runs in QuickJS compiled to WebAssembly with no ambient authority: no `fetch`, `import()` of URLs, timers, `process` or `require` exist in that engine, and its only way out is `host.call()` through `capabilities`, `policy` and the gate. It has real limits (`fuel`, `memoryBytes`, `stackBytes`, `deadlineMs`) and a hard `terminate()` backstop. It does **not** guarantee: that your own capabilities are safe (whatever you grant is reachable, so keep them narrow), protection from engine or WebAssembly-runtime bugs (still shared process memory; for hostile multi-tenant workloads add OS-level isolation), that an in-flight capability is cancelled when the cooperative deadline fires ([andbox#35](https://github.com/johnhenry/andbox/issues/35)), or Node-level hardening (`nodeWorker.permissions` is not supported in this mode). See [`mode: 'wasm'`](#mode-wasm) and [andbox#10](https://github.com/johnhenry/andbox/issues/10).
430
+
424
431
  **What andbox guarantees:**
425
432
 
426
433
  - **No DOM access.** Worker-mode code executes in a real Worker global scope, which has no `document`, `window`, or other DOM references -- this is a platform property of Workers, not something andbox has to enforce itself.
@@ -433,13 +440,13 @@ andbox is **not** a boundary against code that is actively trying to escape it.
433
440
 
434
441
  **What is still yours:**
435
442
 
436
- - **Worker-global APIs are directly reachable, regardless of `capabilities`.** Sandboxed code executes in a real Worker global scope, so `fetch`, `WebSocket`, `Worker` (nested workers), `importScripts`, `indexedDB`, and `self.postMessage` are all callable directly -- omitting a `fetch` capability does not block network access. This is fundamental to how `Function`-based evaluation works and isn't fixable without a different execution strategy: [`mode: 'wasm'`](#mode-wasm) is that strategy (QuickJS in WebAssembly, no ambient authority), and a cross-origin iframe with a strict CSP is another. See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
437
- - **`sandboxImport()` will load and execute an arbitrary remote URL.** Any `http(s)://` specifier is passed straight to `import()` with no allowlist, independent of any network policy configured for capabilities. See [andbox#7](https://github.com/johnhenry/andbox/issues/7).
443
+ - **Worker-global APIs: partly removed, not contained.** Since 0.1.0 the worker prelude deletes `fetch`, `WebSocket`, `WebSocketStream`, `WebTransport`, `EventSource`, `XMLHttpRequest`, `Worker`, `SharedWorker`, `importScripts`, `indexedDB`, `caches`, `BroadcastChannel`, `postMessage` and `self` from the global scope before any evaluated code runs (after the runtime has captured what it needs), shadows those names for evaluated code, and gives evaluated code a throwaway `this`. A script no longer gets them by name, through `globalThis`, indirect `eval` or `Function`. **This is hardening, not a boundary, and a Worker is not a security boundary.** Still reachable: the platform `import()` operator (syntax, it cannot be deleted or shadowed: it can fetch and execute remote code and is an exfiltration channel; `allowedImportHosts` only governs `sandboxImport()`), timing and `SharedArrayBuffer`/`Atomics` side channels, anything the engine or platform adds later that is not on the list above (a deny-list can only ever be incomplete), and under Node `process`, `require` and the rest of the Node API (use `nodeWorker.permissions`, and see [Node](#node)). Everything shares the Worker's realm and heap, so any prototype or intrinsic the code mutates is shared with the runtime. A different isolation primitive is required for hostile code: [`mode: 'wasm'`](#mode-wasm) (QuickJS in WebAssembly, no ambient authority) or a cross-origin iframe with a strict CSP. See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
444
+ - **`sandboxImport()` remote imports: allowed unless `allowedImportHosts` is provided, and only `sandboxImport()` is governed.** With `allowedImportHosts` unset, absolute and protocol-relative `http(s)` specifiers load from any host (0.1.0 denied them by default; 0.1.1 reverted that). Pass `allowedImportHosts: [...]` to restrict to those hostnames plus `baseURL`'s own host (refused with `Import denied: <host> is not in allowedImportHosts`), or `allowedImportHosts: []` to deny all remote imports. Import-map targets and virtual modules are host-authored and unaffected. The check cannot see inside a module once loaded (its own static `import`s) and cannot stop the platform `import()` operator (see the previous item). `mode: 'wasm'` never fetches URLs at all. See [andbox#7](https://github.com/johnhenry/andbox/issues/7).
438
445
  - **A timeout cannot undo in-flight host-side effects; it can ask them to stop.** When the Worker is terminated (timeout, an aborted `evaluate()`, `dispose()`, a crash) every capability call still in flight sees `this.signal` abort, and its late result is dropped rather than delivered. Cancellation is cooperative: a capability that ignores `this.signal` still runs to completion on the host. Write effectful capabilities as `function`s (not arrows) and pass the signal on (`fetch(url, { signal: this.signal })`), and keep them idempotent. In `mode: 'wasm'`, a cooperative `deadlineMs` ends the evaluation without terminating the Worker, so the signal does not abort in that case. See [andbox#8](https://github.com/johnhenry/andbox/issues/8).
439
446
  - **`mode: 'service-worker'` does not provide isolation by merely existing.** It's a hosting mechanism -- a real Service Worker, same-origin by default, serving your `files` map with real fetch/navigation interception. Content served through it can see and touch its own origin exactly like any other same-origin page can; nothing about registering a Service Worker sandboxes what runs inside the pages it serves. If you're hosting content you don't fully trust, point this mode at a genuinely separate origin from day one -- the same recommendation the `fetch`/`WebSocket`/`Worker` item above makes for `worker` mode (a cross-origin iframe with a strict CSP), not something bolted on after the fact. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
440
447
  - **The Service Worker does not control the very first navigation into its scope.** A page/iframe navigation into `scope` that happens *before* the registration has finished activating is a normal, unintercepted network request -- Service Workers never retroactively intercept a request that already went out. `createSandbox({ mode: 'service-worker' })`'s returned promise only resolves once the registration is active (its generated script also calls `clients.claim()` on activate, which helps *already-open* clients but not fresh navigations); the documented, load-bearing contract is: don't navigate anything into `scope` until that promise resolves. Do that and every request is intercepted from the first byte, because the registration already matches `scope` before the navigation request is made. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
441
448
 
442
- If you need to run untrusted/adversarial code safely, andbox alone is not sufficient -- pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
449
+ If you need to run untrusted/adversarial code, use `createSandbox({ untrusted: true })` (`mode: 'wasm'`) and, for hostile multi-tenant workloads, pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
443
450
 
444
451
  ## Threat model by mode
445
452
 
@@ -448,9 +455,9 @@ What each mode is built to stop, and what it is not. "Hostile" means code active
448
455
  | | `worker` / `node-worker` | `wasm` |
449
456
  |---|---|---|
450
457
  | **Runs in** | The Worker's own JS engine (`new Function`) | QuickJS-ng compiled to WebAssembly, inside the Worker / worker thread |
451
- | **Reaching `fetch`, `WebSocket`, `importScripts`, `indexedDB`, `postMessage`** | Possible. They are Worker globals the code can call. | Not possible. The engine has no such globals, and `constructor`/`eval`/`Function` chains only reach the guest realm. |
452
- | **Forging protocol messages to the host** | Possible in principle (`self.postMessage`); ids are random, which only slows a guess. | Not possible. The guest has no `postMessage` or `self`. |
453
- | **`sandboxImport` of arbitrary URLs / Node builtins** | Loaded and executed (browser) or blocked only with `nodeWorker.permissions` (Node). | Refused: only virtual modules resolve; no URL is fetched. |
458
+ | **Reaching `fetch`, `WebSocket`, `importScripts`, `indexedDB`, `postMessage`** | Removed from the global scope by the prelude (0.1.0), so not reachable by name or via `globalThis`/`eval`/`Function`. Deny-list hardening only: `import()`, timing channels and (Node) `process`/`require` remain. | Not possible. The engine has no such globals, and `constructor`/`eval`/`Function` chains only reach the guest realm. |
459
+ | **Forging protocol messages to the host** | `postMessage`/`self` are removed; ids are random UUIDs and each `result` must echo a per-evaluate nonce. | Not possible. The guest has no `postMessage` or `self`. |
460
+ | **`sandboxImport` of arbitrary URLs / Node builtins** | Remote `http(s)` URLs allowed unless `allowedImportHosts` is provided (then only listed hosts; `[]` denies all); the raw `import()` operator is still unrestricted (browser), and Node builtins are blocked only with `nodeWorker.permissions` (Node). | Refused: only virtual modules resolve; no URL is fetched. |
454
461
  | **Prototype-chain names via `host.call`** | Closed by the capability gate (`Object.create(null)`). | Same gate, plus the guest never sees host objects. |
455
462
  | **Infinite loops** | `terminate()` after `timeoutMs`, then a new Worker. | Deterministic `fuel` and a wall-clock `deadlineMs` stop it without a respawn; `terminate()` remains the backstop. |
456
463
  | **Memory exhaustion** | Browser: nothing but the tab limit. Node: opt-in `nodeWorker.maxMemoryMb`. | Guest heap cap plus a hard cap on the engine's linear memory. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.10",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, timeouts, and an optional QuickJS-in-WebAssembly mode",
6
6
  "main": "./src/index.mjs",
package/src/index.d.ts CHANGED
@@ -397,6 +397,20 @@ export interface SandboxOptions {
397
397
  * a live sandbox keeps the process alive until dispose().
398
398
  */
399
399
  unref?: boolean;
400
+ /**
401
+ * Hostnames `sandboxImport()` may load remote http(s) modules from, in
402
+ * addition to the host of `baseURL`. Unset (default): remote imports are
403
+ * allowed. Provided: only these hosts; `[]` denies all remote imports.
404
+ * Import-map targets and virtual modules are not affected. Does not
405
+ * restrict the platform `import()` operator in worker mode.
406
+ */
407
+ allowedImportHosts?: string[];
408
+ /**
409
+ * Convenience for untrusted code: selects `mode: 'wasm'`. Throws if `mode`
410
+ * is set to anything else; rejects (never falls back to a Worker) when
411
+ * wasm mode is unavailable.
412
+ */
413
+ untrusted?: boolean;
400
414
  }
401
415
 
402
416
  /** Options for the built-in Node worker_threads mode (`nodeWorker`). */
package/src/sandbox.mjs CHANGED
@@ -316,6 +316,22 @@ const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'inline', 'data-uri',
316
316
  * @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean }> | Promise<{ scriptURL: string, scope: string, define: Function, remove: Function, dispose: Function, isDisposed: () => boolean }>}
317
317
  */
318
318
  export function createSandbox(options = {}) {
319
+ if (options.untrusted === true) {
320
+ // Convenience for untrusted code: always the WebAssembly engine, never a
321
+ // silent downgrade to a Worker (andbox#10).
322
+ if (options.mode !== undefined && options.mode !== 'wasm') {
323
+ throw new Error(
324
+ `createSandbox({ untrusted: true }) requires mode: 'wasm' (got '${String(options.mode)}'); ` +
325
+ 'worker and inline modes are not a boundary for untrusted code.'
326
+ );
327
+ }
328
+ return Promise.resolve(createSandbox({ ...options, untrusted: false, mode: 'wasm' })).catch((err) => {
329
+ throw new Error(
330
+ `createSandbox({ untrusted: true }) needs mode: 'wasm', which is unavailable here: ${err?.message ?? err}`,
331
+ { cause: err }
332
+ );
333
+ });
334
+ }
319
335
  const mode = options.mode ?? 'worker';
320
336
  if (!SUPPORTED_MODES.includes(mode)) {
321
337
  throw new Error(
@@ -402,8 +418,16 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
402
418
  onConsole,
403
419
  nodeWorker,
404
420
  unref = false,
421
+ allowedImportHosts,
405
422
  } = options;
406
423
 
424
+ // undefined = unset: remote imports allowed. An array (even empty) restricts.
425
+ if (allowedImportHosts !== undefined &&
426
+ (!Array.isArray(allowedImportHosts) || !allowedImportHosts.every((h) => typeof h === 'string'))) {
427
+ throw new TypeError('allowedImportHosts must be an array of hostname strings');
428
+ }
429
+ const importHosts = allowedImportHosts === undefined ? null : allowedImportHosts.map((h) => h.toLowerCase());
430
+
407
431
  // Node mode: no global Worker (and no blob: worker URLs) -> node:worker_threads.
408
432
  // An explicit workerFactory always wins; 'node-worker' forces Node; the
409
433
  // default selects it only when there is no global Worker under Node.
@@ -561,6 +585,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
561
585
  type: 'configure',
562
586
  importMap,
563
587
  baseURL,
588
+ allowedImportHosts: importHosts,
564
589
  virtualModules: Object.fromEntries(virtualModules),
565
590
  ...(wasmConfig ? { wasm: { ...wasmConfig.engine, memoryBytes: wasmConfig.limits.memoryBytes } } : {}),
566
591
  });
@@ -32,12 +32,50 @@ export function makeWorkerSource() {
32
32
  // ── State ──
33
33
  let importMap = { imports: {}, scopes: {} };
34
34
  let baseURL = 'https://andbox.local/';
35
+ let allowedImportHosts = null; // null = unset: remote imports allowed
35
36
  const virtualModules = new Map();
36
37
  // Node mode only: the adapter provides a loader that lets virtual modules
37
38
  // import each other. Captured once and removed from the global scope.
38
39
  const nodeVirtual = globalThis.__andboxNodeVirtual;
39
40
  try { delete globalThis.__andboxNodeVirtual; } catch {}
40
41
 
42
+ // ── Worker-global lockdown (andbox#10, hardening only) ──
43
+ // Capture what the runtime needs, then remove the ambient network/worker APIs
44
+ // so evaluated code does not get them by name, via globalThis, via indirect
45
+ // eval or via Function. This is NOT a security boundary: the platform \`import()\`
46
+ // operator, Atomics/timing channels and, under Node, \`process\`/\`require\` stay
47
+ // reachable. Use mode: 'wasm' (or an OS-level boundary) for hostile code.
48
+ const scope = self;
49
+ const post = self.postMessage.bind(self);
50
+ const closeSelf = typeof self.close === 'function' ? self.close.bind(self) : () => {};
51
+ const LOCKED_GLOBALS = [
52
+ 'fetch', 'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource',
53
+ 'Worker', 'SharedWorker', 'importScripts', 'indexedDB', 'caches', 'BroadcastChannel',
54
+ 'postMessage', 'self',
55
+ ];
56
+ for (const k of LOCKED_GLOBALS) {
57
+ try { delete globalThis[k]; } catch {}
58
+ if (k in globalThis) {
59
+ try { Object.defineProperty(globalThis, k, { value: undefined, writable: false, configurable: false }); } catch {}
60
+ }
61
+ }
62
+ // Names shadowed lexically for evaluated code as well (covers environments
63
+ // where a global could not be deleted).
64
+ const SHADOWED = [...LOCKED_GLOBALS, 'window'];
65
+
66
+ // ── Remote import policy (andbox#7) ──
67
+ function assertImportAllowed(href) {
68
+ if (allowedImportHosts === null) return;
69
+ let u;
70
+ try { u = new URL(href); } catch { return; }
71
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
72
+ const host = u.hostname.toLowerCase();
73
+ let baseHost = '';
74
+ try { baseHost = new URL(baseURL).hostname.toLowerCase(); } catch {}
75
+ if (host === baseHost || allowedImportHosts.includes(host)) return;
76
+ throw new Error(\`Import denied: \${host} is not in allowedImportHosts\`);
77
+ }
78
+
41
79
  // ── Import Map Resolver (inlined) ──
42
80
  function resolveWithImportMap(specifier, map, parentURL) {
43
81
  if (!map) return null;
@@ -93,11 +131,13 @@ async function sandboxImport(specifier) {
93
131
  // 3. Relative/absolute URL — resolve against baseURL
94
132
  if (specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')) {
95
133
  const resolved = new URL(specifier, baseURL).href;
134
+ assertImportAllowed(resolved);
96
135
  return await import(resolved);
97
136
  }
98
137
 
99
138
  // 4. Absolute URL passthrough
100
139
  if (specifier.startsWith('http://') || specifier.startsWith('https://')) {
140
+ assertImportAllowed(specifier);
101
141
  return await import(specifier);
102
142
  }
103
143
 
@@ -113,7 +153,7 @@ function callCapability(name, args) {
113
153
  const id = crypto.randomUUID();
114
154
  return new Promise((resolve, reject) => {
115
155
  pendingRpc.set(id, { resolve, reject });
116
- self.postMessage({ type: 'capabilityCall', id, name, args });
156
+ post({ type: 'capabilityCall', id, name, args });
117
157
  });
118
158
  }
119
159
 
@@ -135,7 +175,7 @@ function makeForwardingConsole(evalId) {
135
175
  try { return typeof a === 'object' ? JSON.stringify(a) : String(a); }
136
176
  catch { return String(a); }
137
177
  });
138
- self.postMessage({ type: 'console', evalId, level: prop, args: serialized });
178
+ post({ type: 'console', evalId, level: prop, args: serialized });
139
179
  };
140
180
  }
141
181
  return target[prop];
@@ -144,23 +184,24 @@ function makeForwardingConsole(evalId) {
144
184
  }
145
185
 
146
186
  // ── Message Handler ──
147
- self.onmessage = async ({ data: msg }) => {
187
+ scope.onmessage = async ({ data: msg }) => {
148
188
  switch (msg.type) {
149
189
  case 'configure': {
150
190
  if (msg.importMap) importMap = msg.importMap;
151
191
  if (msg.baseURL) baseURL = msg.baseURL;
192
+ if (Array.isArray(msg.allowedImportHosts)) allowedImportHosts = msg.allowedImportHosts;
152
193
  if (msg.virtualModules) {
153
194
  for (const [name, src] of Object.entries(msg.virtualModules)) {
154
195
  virtualModules.set(name, src);
155
196
  }
156
197
  }
157
- self.postMessage({ type: 'configured' });
198
+ post({ type: 'configured' });
158
199
  break;
159
200
  }
160
201
 
161
202
  case 'defineModule': {
162
203
  virtualModules.set(msg.name, msg.source);
163
- self.postMessage({ type: 'moduleDefined', name: msg.name });
204
+ post({ type: 'moduleDefined', name: msg.name });
164
205
  break;
165
206
  }
166
207
 
@@ -171,13 +212,14 @@ self.onmessage = async ({ data: msg }) => {
171
212
  // after the user code matter: without the trailing one, code ending in
172
213
  // a // line comment swallows the closing brace (andbox#23).
173
214
  const asyncFn = new Function(
174
- 'sandboxImport', 'host', 'console',
215
+ 'sandboxImport', 'host', 'console', ...SHADOWED,
175
216
  \`return (async () => {\\n\${msg.code}\\n})();\`
176
217
  );
177
- const result = await asyncFn(sandboxImport, host, fwdConsole);
178
- self.postMessage({ type: 'result', id: msg.id, nonce: msg.nonce, success: true, value: serialize(result) });
218
+ // \`this\` is a throwaway object so a bare \`this\` is not the global.
219
+ const result = await asyncFn.call(Object.freeze(Object.create(null)), sandboxImport, host, fwdConsole);
220
+ post({ type: 'result', id: msg.id, nonce: msg.nonce, success: true, value: serialize(result) });
179
221
  } catch (e) {
180
- self.postMessage({
222
+ post({
181
223
  type: 'result',
182
224
  id: msg.id,
183
225
  nonce: msg.nonce,
@@ -207,7 +249,7 @@ self.onmessage = async ({ data: msg }) => {
207
249
  reject(new Error('Sandbox disposed'));
208
250
  }
209
251
  pendingRpc.clear();
210
- self.close();
252
+ closeSelf();
211
253
  break;
212
254
  }
213
255
  }