@johnhenry/andbox 0.0.8 → 0.0.9

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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
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,9 +42,12 @@ 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
52
  gated[name] = async (...args) => {
49
53
  // Measure argument bytes
@@ -83,6 +87,18 @@ export function gateCapabilities(capabilities, policy = {}) {
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/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;
@@ -490,7 +490,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
490
490
 
491
491
  case 'result': {
492
492
  const entry = pending.get(msg.id);
493
- if (entry) {
493
+ if (entry && entry.nonce === msg.nonce) {
494
494
  pending.delete(msg.id);
495
495
  if (entry.timer) clearTimeout(entry.timer);
496
496
  if (msg.stats) {
@@ -570,7 +570,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
570
570
  }
571
571
 
572
572
  async function handleCapabilityCall(rpcId, name, args) {
573
- const fn = gatedCaps[name];
573
+ const fn = lookupCapability(name);
574
574
  if (!fn) {
575
575
  worker.postMessage({
576
576
  type: 'capabilityResult',
@@ -637,6 +637,10 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
637
637
  // can't guess the id of a concurrent evaluate() on the same worker and
638
638
  // forge a matching message to interfere with it.
639
639
  const id = crypto.randomUUID();
640
+ // Per-call nonce: the worker echoes it in its `result`; a result whose
641
+ // nonce does not match is dropped. Held only by the host and the worker's
642
+ // message handler, never exposed to evaluated code.
643
+ const nonce = crypto.randomUUID() + crypto.randomUUID();
640
644
  const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
641
645
  const { promise, resolve, reject } = makeDeferred();
642
646
 
@@ -691,8 +695,8 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
691
695
  }, { once: true });
692
696
  }
693
697
 
694
- pending.set(id, { resolve, reject, timer });
695
- worker.postMessage({ type: 'evaluate', id, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
698
+ pending.set(id, { resolve, reject, timer, nonce });
699
+ worker.postMessage({ type: 'evaluate', id, nonce, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
696
700
 
697
701
  // Restore console handler when evaluation completes
698
702
  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
  });