@johnhenry/andbox 0.0.9 → 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 +1 -1
- package/package.json +1 -1
- package/src/capability-gate.mjs +2 -2
- package/src/index.d.ts +12 -1
- package/src/sandbox.mjs +32 -14
package/README.md
CHANGED
|
@@ -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
|
@@ -49,7 +49,7 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
49
49
|
for (const [name, fn] of Object.entries(capabilities)) {
|
|
50
50
|
const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...(hasOwn(capPolicies, name) ? capPolicies[name] : undefined) };
|
|
51
51
|
|
|
52
|
-
gated[name] = async (...args)
|
|
52
|
+
gated[name] = async function (...args) {
|
|
53
53
|
// Measure argument bytes
|
|
54
54
|
const argStr = JSON.stringify(args);
|
|
55
55
|
const argBytes = new TextEncoder().encode(argStr).byteLength;
|
|
@@ -82,7 +82,7 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
82
82
|
concurrent++;
|
|
83
83
|
|
|
84
84
|
try {
|
|
85
|
-
return await fn(
|
|
85
|
+
return await fn.apply(this, args);
|
|
86
86
|
} finally {
|
|
87
87
|
concurrent--;
|
|
88
88
|
}
|
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
|
@@ -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);
|
|
@@ -570,30 +573,45 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
570
573
|
}
|
|
571
574
|
|
|
572
575
|
async function handleCapabilityCall(rpcId, name, args) {
|
|
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
|
+
|
|
573
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;
|