@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 +2 -2
- package/package.json +1 -1
- package/src/capability-gate.mjs +21 -5
- package/src/index.d.ts +12 -1
- package/src/sandbox.mjs +41 -19
- package/src/wasm-worker-source.mjs +1 -1
- package/src/worker-source.mjs +2 -1
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.
|
|
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
|
|
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.
|
|
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",
|
package/src/capability-gate.mjs
CHANGED
|
@@ -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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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': {
|
package/src/worker-source.mjs
CHANGED
|
@@ -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
|
});
|