@johnhenry/andbox 0.0.8 → 0.0.10

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
@@ -427,7 +427,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
427
427
  - **No implicit host object references.** Only what you explicitly pass in (`capabilities`, `globals`, import map entries) is reachable from sandboxed code -- ordinary (non-adversarial) code cannot accidentally read or mutate host-side state it wasn't given a reference to.
428
428
  - **(Node) A worker thread is not a security boundary.** It shares the process with the host; by default it inherits `process.env` and can reach `process`, `fs`, and `child_process`. The opt-in `nodeWorker.permissions` hardening (see [Node](#node)) adds the permission model, an isolated env, a memory cap and import blocking, which raises the cost of casual abuse but does not make the thread a boundary.
429
429
  - **Hard kill on timeout.** `evaluate()` calls that exceed `timeoutMs` `terminate()` the Worker outright and start a fresh one for the next call -- this is a real process-level kill, not a cooperative cancellation the running code could ignore. See "still yours" below for what a kill does *not* undo.
430
- - **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor. Previously fixed; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
430
+ - **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor; the host resolves names through a `Map` and rejects anything that was not explicitly granted. First fixed in 0.0.1, hardened in 0.0.9; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
431
431
  - **`createNetworkFetch()`'s allowlist is redirect-safe.** Requests are made with `redirect: 'manual'` and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. Previously fixed; see [andbox#6](https://github.com/johnhenry/andbox/issues/6).
432
432
  - **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
433
433
 
@@ -435,7 +435,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
435
435
 
436
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
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).
438
- - **A timeout stops message delivery, not in-flight host-side effects.** If a capability call with a real side effect (a write, an API call) is in flight when the timeout fires, that side effect still completes on the host even though the Worker is killed. Capabilities with real side effects should be designed to be idempotent and/or cancellable via `AbortSignal`. See [andbox#8](https://github.com/johnhenry/andbox/issues/8).
438
+ - **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
439
  - **`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
440
  - **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
441
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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",
@@ -21,11 +21,12 @@ import { DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
21
21
  *
22
22
  * @param {Record<string, Function>} capabilities - Raw capability functions.
23
23
  * @param {GatePolicy} [policy] - Rate limit policy.
24
- * @returns {{ gated: Record<string, Function>, stats: () => object }}
24
+ * @returns {{ gated: Record<string, Function>, lookup: (name: unknown) => Function | undefined, stats: () => object }}
25
25
  */
26
26
  export function gateCapabilities(capabilities, policy = {}) {
27
27
  const limits = { ...DEFAULT_LIMITS, ...policy.limits };
28
28
  const capPolicies = policy.capabilities || {};
29
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
29
30
 
30
31
  let totalCalls = 0;
31
32
  let totalArgBytes = 0;
@@ -41,11 +42,14 @@ export function gateCapabilities(capabilities, policy = {}) {
41
42
  // like 'constructor' or 'toString' cannot resolve through the prototype
42
43
  // to real global functions and bypass the allowlist/rate limiting below.
43
44
  const gated = Object.create(null);
45
+ // Authoritative table for lookup(): a Map has no prototype-chain property
46
+ // semantics at all, so a string key either was granted or is absent.
47
+ const table = new Map();
44
48
 
45
49
  for (const [name, fn] of Object.entries(capabilities)) {
46
- const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...capPolicies[name] };
50
+ const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...(hasOwn(capPolicies, name) ? capPolicies[name] : undefined) };
47
51
 
48
- gated[name] = async (...args) => {
52
+ gated[name] = async function (...args) {
49
53
  // Measure argument bytes
50
54
  const argStr = JSON.stringify(args);
51
55
  const argBytes = new TextEncoder().encode(argStr).byteLength;
@@ -78,11 +82,23 @@ export function gateCapabilities(capabilities, policy = {}) {
78
82
  concurrent++;
79
83
 
80
84
  try {
81
- return await fn(...args);
85
+ return await fn.apply(this, args);
82
86
  } finally {
83
87
  concurrent--;
84
88
  }
85
89
  };
90
+ table.set(name, gated[name]);
91
+ }
92
+
93
+ /**
94
+ * Resolve a capability by name. Only names the host explicitly granted
95
+ * resolve; non-strings and anything that exists only on Object.prototype
96
+ * (constructor, toString, __proto__, ...) return undefined.
97
+ * @param {unknown} name
98
+ * @returns {Function | undefined}
99
+ */
100
+ function lookup(name) {
101
+ return typeof name === 'string' ? table.get(name) : undefined;
86
102
  }
87
103
 
88
104
  function stats() {
@@ -94,5 +110,5 @@ export function gateCapabilities(capabilities, policy = {}) {
94
110
  };
95
111
  }
96
112
 
97
- return { gated, stats };
113
+ return { gated, lookup, stats };
98
114
  }
package/src/index.d.ts CHANGED
@@ -310,12 +310,23 @@ export interface EvaluateOptions {
310
310
  deadlineMs?: number;
311
311
  }
312
312
 
313
+ /**
314
+ * `this` inside a capability function (use a `function`, not an arrow).
315
+ * `signal` aborts when the sandbox's Worker is terminated (timeout, an
316
+ * aborted `evaluate()`, `dispose()`, crash); cooperative capabilities should
317
+ * pass it to whatever they are doing (`fetch(url, { signal })`, ...).
318
+ */
319
+ export interface CapabilityContext {
320
+ signal: AbortSignal;
321
+ name: string;
322
+ }
323
+
313
324
  /** Options for createSandbox(). */
314
325
  export interface SandboxOptions {
315
326
  /** Import map for module resolution inside the sandbox. */
316
327
  importMap?: ImportMap;
317
328
  /** Host functions callable from sandbox code via host.call(name, ...args). */
318
- capabilities?: Record<string, (...args: any[]) => any>;
329
+ capabilities?: Record<string, (this: CapabilityContext, ...args: any[]) => any>;
319
330
  /** Default timeout in milliseconds for evaluate() calls. */
320
331
  defaultTimeoutMs?: number;
321
332
  /** Base URL for resolving relative imports inside the sandbox. */
package/src/sandbox.mjs CHANGED
@@ -436,7 +436,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
436
436
  }
437
437
 
438
438
  // Gate capabilities with rate limits
439
- const { gated: gatedCaps, stats: gateStats } = gateCapabilities(capabilities, policy);
439
+ const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(capabilities, policy);
440
440
 
441
441
  // Console handler — mutable so evaluate() can swap per-call
442
442
  let activeConsoleHandler = onConsole || null;
@@ -446,6 +446,8 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
446
446
  let disposed = false;
447
447
  let worker = null;
448
448
  let workerBlobURL = null;
449
+ // Lifetime of the current Worker; aborted when it is terminated.
450
+ let workerAbort = null;
449
451
 
450
452
  // `unref`: the thread only keeps the host process alive while work is in
451
453
  // flight (startup, evaluate, defineModule); idle, it lets the process exit.
@@ -467,6 +469,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
467
469
  // ── Worker lifecycle ──
468
470
 
469
471
  function createWorker() {
472
+ workerAbort = new AbortController();
470
473
  const source = isWasm ? makeWasmWorkerSource() : makeWorkerSource();
471
474
  if (workerFactory) {
472
475
  worker = workerFactory(source);
@@ -490,7 +493,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
490
493
 
491
494
  case 'result': {
492
495
  const entry = pending.get(msg.id);
493
- if (entry) {
496
+ if (entry && entry.nonce === msg.nonce) {
494
497
  pending.delete(msg.id);
495
498
  if (entry.timer) clearTimeout(entry.timer);
496
499
  if (msg.stats) {
@@ -570,30 +573,45 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
570
573
  }
571
574
 
572
575
  async function handleCapabilityCall(rpcId, name, args) {
573
- const fn = gatedCaps[name];
576
+ // Bind to the worker that made the call. If it is killed (timeout, abort,
577
+ // dispose, crash) while the capability is still running, the late result
578
+ // must be dropped: `worker` is then null or a *different* replacement
579
+ // thread that never asked for it (andbox#30).
580
+ const callerWorker = worker;
581
+ const callerSignal = workerAbort?.signal;
582
+ if (!callerWorker || !callerSignal) return;
583
+ const reply = (payload) => {
584
+ if (worker !== callerWorker || callerSignal.aborted) return;
585
+ try {
586
+ callerWorker.postMessage({ type: 'capabilityResult', id: rpcId, ...payload });
587
+ } catch {
588
+ // the thread went away between the check and the post: nothing to tell
589
+ }
590
+ };
591
+
592
+ const fn = lookupCapability(name);
574
593
  if (!fn) {
575
- worker.postMessage({
576
- type: 'capabilityResult',
577
- id: rpcId,
578
- success: false,
579
- error: `Unknown capability: ${name}`,
580
- });
594
+ reply({ success: false, error: `Unknown capability: ${name}` });
581
595
  return;
582
596
  }
583
597
  try {
584
- const value = await fn(...args);
585
- worker.postMessage({ type: 'capabilityResult', id: rpcId, success: true, value });
598
+ // The signal travels as `this`, not as an argument, so capabilities
599
+ // with their own arity (Math.max, rest params, ...) are unaffected.
600
+ // It aborts when this Worker is terminated (andbox#8).
601
+ const value = await fn.call({ signal: callerSignal, name }, ...args);
602
+ reply({ success: true, value });
586
603
  } catch (e) {
587
- worker.postMessage({
588
- type: 'capabilityResult',
589
- id: rpcId,
590
- success: false,
591
- error: e.message || String(e),
592
- });
604
+ reply({ success: false, error: e?.message || String(e) });
593
605
  }
594
606
  }
595
607
 
596
608
  function terminateWorker() {
609
+ if (workerAbort) {
610
+ // Tell in-flight capability calls their caller is gone so cooperative
611
+ // ones can cancel the underlying effect (andbox#8).
612
+ workerAbort.abort(new Error('Sandbox worker terminated'));
613
+ workerAbort = null;
614
+ }
597
615
  if (worker) {
598
616
  worker.terminate();
599
617
  worker = null;
@@ -637,6 +655,10 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
637
655
  // can't guess the id of a concurrent evaluate() on the same worker and
638
656
  // forge a matching message to interfere with it.
639
657
  const id = crypto.randomUUID();
658
+ // Per-call nonce: the worker echoes it in its `result`; a result whose
659
+ // nonce does not match is dropped. Held only by the host and the worker's
660
+ // message handler, never exposed to evaluated code.
661
+ const nonce = crypto.randomUUID() + crypto.randomUUID();
640
662
  const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
641
663
  const { promise, resolve, reject } = makeDeferred();
642
664
 
@@ -691,8 +713,8 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
691
713
  }, { once: true });
692
714
  }
693
715
 
694
- pending.set(id, { resolve, reject, timer });
695
- worker.postMessage({ type: 'evaluate', id, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
716
+ pending.set(id, { resolve, reject, timer, nonce });
717
+ worker.postMessage({ type: 'evaluate', id, nonce, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
696
718
 
697
719
  // Restore console handler when evaluation completes
698
720
  return promise.finally(() => {
@@ -462,7 +462,7 @@ function wasmWorkerMain() {
462
462
  }
463
463
  case 'evaluate': {
464
464
  const res = await runEval(msg);
465
- self.postMessage({ type: 'result', id: msg.id, ...res });
465
+ self.postMessage({ type: 'result', id: msg.id, nonce: msg.nonce, ...res });
466
466
  break;
467
467
  }
468
468
  case 'capabilityResult': {
@@ -175,11 +175,12 @@ self.onmessage = async ({ data: msg }) => {
175
175
  \`return (async () => {\\n\${msg.code}\\n})();\`
176
176
  );
177
177
  const result = await asyncFn(sandboxImport, host, fwdConsole);
178
- self.postMessage({ type: 'result', id: msg.id, success: true, value: serialize(result) });
178
+ self.postMessage({ type: 'result', id: msg.id, nonce: msg.nonce, success: true, value: serialize(result) });
179
179
  } catch (e) {
180
180
  self.postMessage({
181
181
  type: 'result',
182
182
  id: msg.id,
183
+ nonce: msg.nonce,
183
184
  success: false,
184
185
  error: { message: e.message || String(e), name: e.name || 'Error', stack: e.stack },
185
186
  });