@johnhenry/andbox 0.1.2 → 0.2.0

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
@@ -22,6 +22,7 @@ Zero dependencies. Uses only Web Workers and standard browser APIs.
22
22
  - [Node](#node)
23
23
  - [`mode: 'wasm'`](#mode-wasm)
24
24
  - [`mode: 'iframe'`](#mode-iframe)
25
+ - [Mediated network: `network`](#mediated-network-network)
25
26
  - [API](#api)
26
27
  - [Execution model](#execution-model)
27
28
  - [Security model](#security-model)
@@ -275,7 +276,7 @@ pane.iframe.style.height = '300px'; // the live element; size it like any other
275
276
 
276
277
  **Differences from worker mode.**
277
278
 
278
- - **The code has a full browser window.** `window`, `document`, `fetch`, timers and `requestAnimationFrame` are the frame's own; nothing is deleted or shadowed (the worker-mode lockdown does not apply, the origin boundary does). `document.body.append(...)` renders where you mounted the frame.
279
+ - **The code has a full browser window.** `window`, `document`, `fetch`, timers and `requestAnimationFrame` are the frame's own; nothing is deleted or shadowed (the worker-mode lockdown does not apply, the origin boundary does). `document.body.append(...)` renders where you mounted the frame. With [`network`](#mediated-network-network), `fetch` is replaced by the host-backed one.
279
280
  - **`sandbox.iframe` is the live element, and it changes.** A timeout, an abort, or the frame navigating/reloading itself replaces it with a new element in the same place, with the same attributes (`class`, `style`, `width`, ...); everything the old document held is gone. `onFrame(iframe)` is called for every new frame before it is attached, so set things up there if you need them on every frame, or insert the frame yourself there. `sandbox.iframe` is `null` after `dispose()`.
280
281
  - **Do not move the element in the DOM.** Re-parenting an iframe reloads its document. andbox notices (the pending call rejects with `Sandbox iframe unloaded ...`) and the next call gets a fresh frame, but state is lost. Pass `container` (or place it in `onFrame`) instead.
281
282
  - **Remote modules are cross-origin requests.** The frame's origin is `null`, so a module URL must be served with `Access-Control-Allow-Origin` (CDNs such as esm.sh do). A relative `sandboxImport('./x.js')` resolves against `baseURL` (default: your page URL) and needs the same header.
@@ -291,6 +292,71 @@ pane.iframe.style.height = '300px'; // the live element; size it like any other
291
292
 
292
293
  Use `mode: 'wasm'` (fuel and deadline inside the engine) or `worker` mode for code that may spin, and `iframe` mode for code that needs the DOM. `examples/09-iframe-browser/` is a working two-pane notebook demo; `test/browser/iframe-mode.spec.mjs` runs the contract in Chromium, Firefox and WebKit.
293
294
 
295
+ ## Mediated network: `network`
296
+
297
+ Worker mode removes `fetch` from the sandbox. Code you hand a capability to can call `host.call(...)`, but a library imported into the sandbox (`d3.json()`, `ky`, an API client) calls the **global** `fetch` and fails. The `network` option (0.1.3, [andbox#39](https://github.com/johnhenry/andbox/issues/39)) installs a global `fetch` in the sandbox that sends every request to a function on the host, which decides what happens. **No network unless you list hosts:** since 0.2.0 ([andbox#43](https://github.com/johnhenry/andbox/issues/43)) `network.allowedHosts` is required, so setting `network` never means "every host" by accident:
298
+
299
+ ```js
300
+ import { createSandbox } from '@johnhenry/andbox';
301
+
302
+ const sandbox = await createSandbox({
303
+ network: {
304
+ allowedHosts: ['api.example.com'], // required: checked on the host before fetch is called
305
+ // Optional. Runs on the host for every allowed request. Same signature as fetch.
306
+ async fetch(url, init) {
307
+ console.log(init.method, url); // you see every request
308
+ return fetch(url, { ...init, referrerPolicy: 'no-referrer' }); // init.credentials is 'omit'
309
+ },
310
+ },
311
+ policy: { capabilities: { fetch: { maxCalls: 100 } } }, // the usual gate applies
312
+ });
313
+
314
+ await sandbox.defineModule('lib', `export const getJSON = async (u) => (await fetch(u)).json();`);
315
+ await sandbox.evaluate(`
316
+ const { getJSON } = await sandboxImport('lib'); // knows nothing about andbox
317
+ return getJSON('https://api.example.com/items');
318
+ `);
319
+ ```
320
+
321
+ **Inside the sandbox**, `fetch(input, init)` resolves relative URLs against `baseURL`, refuses anything that is not `http:`/`https:` with a `TypeError`, lets the platform normalise the method, headers and body (strings, typed arrays, `Blob`, `FormData`, `URLSearchParams`, a `Request`), and sends only the URL, method, header pairs, body and `redirect` mode to the host. `credentials`, `mode`, `cache`, `referrer` and the other `RequestInit` fields are dropped. It resolves with a real `Response` (`status`, `statusText`, `headers`, `url`, `redirected`, `json()`/`text()`/`arrayBuffer()`/`body`). A host error rejects with `TypeError('fetch failed: ...')`, and `init.signal` rejects the call with `AbortError`. Everything else on the lockdown list (`XMLHttpRequest`, `WebSocket`, `EventSource`, `WebTransport`, `importScripts`, ...) stays removed.
322
+
323
+ **On the host**, the request becomes an ordinary capability named `fetch`, so it goes through the capability gate and `policy` like any other (`policy.capabilities.fetch` limits it; `stats().gate.perCapability.fetch` counts it; binary bodies travel as base64, so `maxArgBytes` counts them). The host side treats it as untrusted input, because evaluated code can also call `host.call('fetch', url, init)` directly: it checks the URL again (http(s) only), validates the method, header pairs, body and `redirect`, checks the host against `allowedHosts`, and then calls your function as `fetch(url, init)` with `init.headers` a `Headers`, `init.body` a string or `Uint8Array`, `init.credentials` set by the host, and `init.signal` aborted when the sandbox is terminated (it is called without a `this`, so `network: { allowedHosts, fetch }` with the platform's own `fetch` works). Return a `Response`, or a plain `{ status, statusText, headers, body, url, redirected }` (`body` a string, `ArrayBuffer`, typed array or `Blob`). `Set-Cookie` is never passed to the sandbox, and opaque or error responses (`status` outside 200-599) reject.
324
+
325
+ | `network` field | Type | Default | Description |
326
+ |---|---|---|---|
327
+ | `allowedHosts` | `string[] \| (url: URL) => boolean \| '*'` | **required** | Which hosts the sandbox may reach, checked on the host before `fetch` is called (forms below). Leaving it out throws at `createSandbox()`. |
328
+ | `fetch` | `(url, init) => Response \| { status, headers, body, ... }` | the platform's global `fetch` | Host function for every request `allowedHosts` lets through. |
329
+ | `credentials` | `'omit' \| 'same-origin' \| 'include'` | `'omit'` | What the host passes as `init.credentials`. The sandbox cannot change it. |
330
+
331
+ **`allowedHosts` takes one of three forms:**
332
+
333
+ ```js
334
+ // 1. A list of hostnames: the sandbox reaches these and nothing else.
335
+ network: { allowedHosts: ['api.example.com', 'cdn.example.com'] }
336
+
337
+ // 2. A function, asked on the host for every request (and every redirect hop):
338
+ // for an allowlist that changes while the sandbox runs, like a notebook
339
+ // that asks the user per host. Only `true` (or a promise of it) allows.
340
+ const approved = new Set(['api.example.com']);
341
+ network: { allowedHosts: (url) => approved.has(url.hostname) }
342
+
343
+ // 3. The explicit opt-in to any http(s) host. Your fetch is then the whole
344
+ // policy, so write it next to one that enforces its own rules.
345
+ network: { allowedHosts: '*', fetch: myPolicyFetch }
346
+ ```
347
+
348
+ - **A list** matches the request URL's hostname exactly: case-insensitive, normalised the way `URL` does it (IDN to punycode, IPv4 forms to dotted decimal), on any port and either scheme. There is no subdomain or wildcard matching: `'example.com'` does not allow `api.example.com`, and `'api.example.com'` does not allow `example.com`. Write IPv6 in brackets (`'[::1]'`). An entry with a scheme, port, path or `*` throws instead of silently never matching, and an empty list throws (to give the sandbox no network, leave `network` out). It is [`createNetworkFetch()`](#createnetworkfetchallowedhosts-fetchfn) in front of your `fetch`.
349
+ - **A function** gets a fresh `URL` (changing it does not change the request). Anything other than `true` refuses with `Network access denied: <host> is not allowed by network.allowedHosts`; if it throws, the sandbox's `fetch` rejects with that error's message, which is a good place for "allow this host in settings". It may be `async`.
350
+ - **`'*'`** skips the host check (the URL must still be http(s)) and leaves everything else, redirects included, to your `fetch`. Without a `fetch`, it is the platform's `fetch` for any host.
351
+
352
+ **Redirects.** With a list, every request is made with `redirect: 'manual'` and any redirect response is refused, so an allowlisted host cannot send the request elsewhere. With a function, andbox follows redirects itself: each request is made with `redirect: 'manual'`, and for every `Location` it asks your function again before requesting it (at most 20 hops; 301/302 after a `POST` and 303 become a `GET` without a body, as the Fetch standard does; `Authorization` is dropped when the hop changes origin). The sandbox sees the final `url` and `redirected: true`; `redirect: 'error'` from the sandbox rejects on a redirect and `redirect: 'manual'` returns it unfollowed. A browser's own `fetch` hides a manual redirect's target (an `opaqueredirect` response), so over the page's `fetch` a function policy cannot check the next hop and the request **fails** rather than following it blindly; on Node, Deno and Bun, or with a `network.fetch` that returns the 3xx response, redirects are followed and checked. With `'*'`, your `fetch` receives the sandbox's `redirect` mode and decides; the platform `fetch` follows redirects to any host.
353
+
354
+ **On a server, the host function has the server's network position.** With `node-worker` (or any server-side host), whatever `allowedHosts` lets through is fetched from inside your network: `localhost`, private addresses, and cloud metadata endpoints such as `169.254.169.254` are reachable if the policy allows them. Prefer a list; with a function or `'*'`, refuse private and link-local addresses yourself (and remember DNS can point a public name at one).
355
+
356
+ **Modes.** `worker` and `node-worker`: the shim is the only `fetch` the code can reach by name. `iframe`: it replaces the frame's own `fetch`, but the frame still has its other network APIs (`XMLHttpRequest`, `WebSocket`, `<img>`, `import()`); add `csp: "connect-src 'none'"` (and `default-src` as needed) to leave the host function as the only way to fetch, since the shim talks to the host over a `MessagePort` that CSP does not affect. `wasm`, `inline`, `data-uri` and `service-worker` throw if `network` is given (in `wasm`, expose a capability and use `host.call()`). Passing both `network` and a capability named `fetch` throws.
357
+
358
+ **Limits.** The response body is buffered on the host and copied into the sandbox (no streaming; enforce size limits in your function, for example from `content-length` and `arrayBuffer().byteLength`). Aborting the sandbox-side `signal` rejects the call immediately but does not cancel the host request; terminating the sandbox does (`init.signal`). The rebuilt `Response` has `type: 'default'`. What this does and does not narrow is in the [Security model](#security-model).
359
+
294
360
  ## API
295
361
 
296
362
  ### `createSandbox(options?)`
@@ -319,6 +385,7 @@ Creates a new sandboxed runtime. Returns a promise (Worker, wasm and iframe mode
319
385
  | `iframeSandbox` | `string[]` | `[]` | `mode: 'iframe'`: extra sandbox tokens (`allow-scripts` is always set); `'allow-same-origin'` throws unless `dangerouslyAllowSameOrigin` |
320
386
  | `dangerouslyAllowSameOrigin` | `boolean` | `false` | `mode: 'iframe'`: permit `'allow-same-origin'`, which removes the origin boundary |
321
387
  | `onFrame` | `(iframe) => void` | -- | `mode: 'iframe'`: called with every new frame (first and after each restart) before it is attached |
388
+ | `network` | `{ allowedHosts, fetch?, credentials? }` | unset | `worker`, `node-worker`, `iframe`: install a global `fetch` in the sandbox that sends every request through the host function (the gated `fetch` capability). `allowedHosts` is required (a hostname list, a `(url) => boolean` function, or `'*'`); without it `createSandbox()` throws. **Unset: no `fetch` in worker modes** (unchanged). See [Mediated network](#mediated-network-network). |
322
389
 
323
390
  **Returns (Worker mode):** `Promise<{ evaluate, defineModule, dispose, stats, isDisposed }>`. `mode: 'iframe'` adds `iframe`, the live `HTMLIFrameElement` (`null` after `dispose()`, replaced after a restart; see [`mode: 'iframe'`](#mode-iframe)).
324
391
 
@@ -397,7 +464,7 @@ registry.dispose(); // revokes blob URLs / drop
397
464
 
398
465
  ### `createNetworkFetch(allowedHosts?, fetchFn?)`
399
466
 
400
- Creates a fetch function that checks the request hostname against an allowlist before calling through. Useful for keeping cooperative code pointed at the hosts you intend -- **not redirect-safe** (see [Security model](#security-model)): an allowlisted host that responds with a redirect is followed without re-checking the final URL.
467
+ Creates a fetch function that checks the request hostname against an allowlist before calling through. Requests are made with `redirect: 'manual'` and any redirect response is rejected, so an allowlisted host cannot send the caller elsewhere (see [Security model](#security-model)). It is what `network: { allowedHosts: [...] }` puts in front of the sandbox's `fetch`. Unlike `network.allowedHosts`, a missing or empty list here still allows every host ([andbox#44](https://github.com/johnhenry/andbox/issues/44)).
401
468
 
402
469
  ```js
403
470
  import { createNetworkFetch } from '@johnhenry/andbox';
@@ -415,9 +482,9 @@ Creates an async iterable stream for console output capture.
415
482
 
416
483
  Promise and error utilities used internally, also available for consumers.
417
484
 
418
- ### `makeWorkerSource()`
485
+ ### `makeWorkerSource(options?)`
419
486
 
420
- Returns the Worker script source code as a string (useful for custom Worker setups).
487
+ Returns the Worker script source code as a string (useful for custom Worker setups). `makeWorkerSource({ networkFetch: true })` is the variant `network` uses: `fetch` is the host-backed shim, which calls the host's `fetch` capability (your host must answer `capabilityCall` messages for it, as `createSandbox()` does).
421
488
 
422
489
  ### `createSandbox({ mode: 'service-worker', ... })`
423
490
 
@@ -497,8 +564,10 @@ andbox is **not** a boundary against code that is actively trying to escape it.
497
564
 
498
565
  **What is still yours:**
499
566
 
500
- - **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).
567
+ - **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; with `network` set, `fetch` is replaced by the host-backed shim instead), 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).
501
568
  - **`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).
569
+ - **`network`: no network unless you list hosts.** `network.allowedHosts` is required (0.2.0, [andbox#43](https://github.com/johnhenry/andbox/issues/43)): a list of hostnames, a function asked about every request and redirect hop, or the explicit `'*'`, which hands the whole decision to your `fetch`. Setting `network` without it throws, so it can no longer mean "every host your function will fetch" by omission.
570
+ - **`network` narrows `fetch` to what `allowedHosts` and your host function allow; it does not close the other routes out.** With `network` set, the sandbox's global `fetch` is a shim: each request goes through the gated `fetch` capability, http(s) only, is checked against `allowedHosts` on the host, and reaches your function with `credentials` (default `'omit'`) chosen by the host and `Set-Cookie` withheld. That is a policy point for well-behaved code and the libraries it imports, not a wall: in worker mode the platform `import()` operator can still fetch (and run) arbitrary URLs and carry data out in them, as can `sandboxImport()` unless `allowedImportHosts` restricts it; in iframe mode the frame's own `XMLHttpRequest`, `WebSocket`, `<img>`, `<form>` and `import()` remain unless `csp` blocks them. Your function is what talks to the network on the sandbox's behalf with the host's network position (a server-side host can reach your internal network and cloud metadata endpoints): prefer an `allowedHosts` list, refuse private addresses in a function or `'*'` policy, and do not forward the sandbox's headers to hosts that trust them blindly. With `'*'`, redirects are whatever your `fetch` does. See [Mediated network](#mediated-network-network) and [andbox#39](https://github.com/johnhenry/andbox/issues/39).
502
571
  - **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).
503
572
  - **`mode: 'iframe'`: the origin boundary is the whole guarantee.** Still yours:
504
573
  - **Network.** The frame has `fetch`, `WebSocket`, `import()`, `<img>`, `<form>` and so on, as a `null`-origin client: it can exfiltrate anything it was given or computed, and reach any server that answers cross-origin requests. Pass a `csp` (`default-src 'none'` plus what the code needs) to restrict it.
@@ -519,7 +588,7 @@ What each mode is built to stop, and what it is not. "Hostile" means code active
519
588
  | | `worker` / `node-worker` | `wasm` | `iframe` |
520
589
  |---|---|---|---|
521
590
  | **Runs in** | The Worker's own JS engine (`new Function`) | QuickJS-ng compiled to WebAssembly, inside the Worker / worker thread | The browser's JS engine, in a sandboxed opaque-origin frame (`new Function` in the frame's realm) |
522
- | **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. | Available, as the frame's own (`null`-origin) APIs; restrict the network with `csp`. Your page, cookies and storage are not reachable (`SecurityError`). |
591
+ | **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`; with `network`, `fetch` is a shim that goes through your host 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. | Available, as the frame's own (`null`-origin) APIs (with `network`, `fetch` is the host-backed shim); restrict the network with `csp`. Your page, cookies and storage are not reachable (`SecurityError`). |
523
592
  | **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`. | Only over its own `MessagePort`, with the same random ids and nonce; the host accepts nothing else from the frame. |
524
593
  | **`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. | As worker mode (`allowedImportHosts` governs `sandboxImport()` only); requests are cross-origin from `null`. `csp` can restrict `import()` too. |
525
594
  | **Prototype-chain names via `host.call`** | Closed by the capability gate (`Object.create(null)`). | Same gate, plus the guest never sees host objects. | Same gate; separate realm, so no shared intrinsics. |
@@ -553,15 +622,6 @@ code-based tool execution.
553
622
  is the source of truth for what that capability gate does and does not
554
623
  guarantee -- the middleware's Security model section points back here
555
624
  rather than repeating it.
556
- - **[`@johnhenry/prism`](https://github.com/johnhenry/prism)** -- a live
557
- HTTP request inspector/proxy whose custom script-route feature runs
558
- user-provided route handlers via `createSandbox({ mode: 'inline' })`.
559
- Deliberately uses `inline` mode, not the default `worker` mode: the
560
- handler needs a live `Request` object (with its body stream) directly in
561
- scope, which can't cross a Worker's structured-clone boundary, and the
562
- feature's predecessor (a package called `vimble`) never provided real
563
- isolation either -- `inline`'s explicit "no isolation, code you already
564
- trust" framing is the honest match, not a downgrade from what came before.
565
625
 
566
626
  ## License
567
627
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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",
@@ -27,9 +27,13 @@ const OFFSCREEN_STYLE =
27
27
  /** Attributes andbox owns on the frame; never copied onto a replacement frame. */
28
28
  const OWNED_ATTRIBUTES = new Set(['srcdoc', 'src', 'sandbox']);
29
29
 
30
- /** The runtime script the frame runs: Worker mode's, minus the global lockdown. */
31
- export function makeIframeRuntimeSource() {
32
- return makeRuntimeSource({ lockdown: false });
30
+ /**
31
+ * The runtime script the frame runs: Worker mode's, minus the global lockdown.
32
+ * @param {{ networkFetch?: boolean }} [options] `networkFetch`: replace the
33
+ * frame's `fetch` with the host-backed one (`createSandbox({ network })`).
34
+ */
35
+ export function makeIframeRuntimeSource({ networkFetch = false } = {}) {
36
+ return makeRuntimeSource({ lockdown: false, networkFetch });
33
37
  }
34
38
 
35
39
  /**
package/src/index.d.ts CHANGED
@@ -216,6 +216,85 @@ export declare function createNetworkFetch(
216
216
  fetchFn?: typeof globalThis.fetch,
217
217
  ): (url: string, init?: RequestInit) => Promise<Response>;
218
218
 
219
+ // ── network (createSandbox({ network }), andbox#39) ──
220
+
221
+ /**
222
+ * What the host function in `network.fetch` receives as `init`. Built by
223
+ * andbox from the sandbox's request after validation; `credentials` and
224
+ * `signal` are always the host's.
225
+ */
226
+ export interface SandboxFetchInit {
227
+ method: string;
228
+ headers: Headers;
229
+ /**
230
+ * A string body as given; any other body as a `Uint8Array` (typed as
231
+ * `BufferSource` so `init` can be passed straight to `fetch`). Absent for
232
+ * GET/HEAD and empty bodies.
233
+ */
234
+ body?: string | BufferSource;
235
+ redirect?: RequestRedirect;
236
+ /** `network.credentials` (default `'omit'`); the sandbox cannot change it. */
237
+ credentials: RequestCredentials;
238
+ /** Aborts when the sandbox's Worker/frame is terminated (timeout, abort, dispose). */
239
+ signal?: AbortSignal;
240
+ }
241
+
242
+ /** A plain-object reply a `network.fetch` host function may return instead of a `Response`. */
243
+ export interface SandboxFetchReply {
244
+ /** 200-599. Default 200. */
245
+ status?: number;
246
+ statusText?: string;
247
+ headers?: HeadersInit;
248
+ body?: string | ArrayBuffer | ArrayBufferView | Blob | null;
249
+ /** Default: the request URL. */
250
+ url?: string;
251
+ redirected?: boolean;
252
+ }
253
+
254
+ /**
255
+ * `network.allowedHosts` as a function: asked on the host, with a fresh `URL`,
256
+ * before every request the sandbox makes and before every redirect hop andbox
257
+ * follows. Only `true` (or a promise of `true`) allows; anything else refuses,
258
+ * and a thrown error is the message the sandbox's `fetch` rejects with.
259
+ */
260
+ export type SandboxHostPolicy = (url: URL) => boolean | Promise<boolean>;
261
+
262
+ /** `createSandbox({ network })`: a host-backed global `fetch` inside the sandbox. */
263
+ export interface SandboxNetworkOptions {
264
+ /**
265
+ * Required (0.2.0, andbox#43): which hosts the sandbox may reach. Checked on
266
+ * the host before `fetch` is called, so leaving it out is an error rather
267
+ * than "every host".
268
+ *
269
+ * - `string[]`: hostnames, matched exactly against the request URL's
270
+ * hostname (case-insensitive, IDN and IPv4 normalised like `URL`, any
271
+ * port, http or https). No subdomain matching: `'example.com'` does not
272
+ * allow `'api.example.com'`. IPv6 in brackets (`'[::1]'`). Entries with a
273
+ * scheme, port, path or `*` throw. Must not be empty. Any redirect is
274
+ * refused (the request is made with `redirect: 'manual'`).
275
+ * - `(url: URL) => boolean | Promise<boolean>`: a policy asked for every
276
+ * request, so the allowlist can change while the sandbox runs. andbox
277
+ * follows redirects itself (`redirect: 'manual'` underneath) and asks it
278
+ * again for each hop; where the platform hides the target (a browser's
279
+ * opaque redirect) the request fails.
280
+ * - `'*'`: any http(s) host. Your `fetch` is the whole policy, and the
281
+ * sandbox's redirect mode is passed to it unchanged.
282
+ */
283
+ allowedHosts: readonly string[] | '*' | SandboxHostPolicy;
284
+ /**
285
+ * Called on the host for every request the sandbox's `fetch` makes that
286
+ * `allowedHosts` allows, through the gated `fetch` capability
287
+ * (`policy.capabilities.fetch` applies). The URL is always absolute
288
+ * http(s). Return a `Response` or a plain reply. Called without a `this`,
289
+ * so the platform `fetch` itself can be passed. Default: the platform's
290
+ * global `fetch` (on a server, that has the server's network position).
291
+ */
292
+ fetch?: (url: string, init: SandboxFetchInit) =>
293
+ Response | SandboxFetchReply | Promise<Response | SandboxFetchReply>;
294
+ /** `init.credentials` for every request. Default `'omit'`. */
295
+ credentials?: RequestCredentials;
296
+ }
297
+
219
298
  // ── stdio ──
220
299
 
221
300
  /** An async iterable stdio stream with push/end controls. */
@@ -243,9 +322,12 @@ export declare function createStdio(): StdioStream;
243
322
  * from the host, and sends configured, moduleDefined, result, capabilityCall,
244
323
  * and console messages back.
245
324
  *
325
+ * @param options.networkFetch Install the host-backed global `fetch` shim
326
+ * (what `createSandbox({ network })` uses). It calls the host's `fetch`
327
+ * capability. Default false: `fetch` is removed like the other network globals.
246
328
  * @returns The complete Worker script source code as a string.
247
329
  */
248
- export declare function makeWorkerSource(): string;
330
+ export declare function makeWorkerSource(options?: { networkFetch?: boolean }): string;
249
331
 
250
332
  // ── service-worker-source ──
251
333
 
@@ -412,6 +494,17 @@ export interface SandboxOptions {
412
494
  * wasm mode is unavailable.
413
495
  */
414
496
  untrusted?: boolean;
497
+ /**
498
+ * `worker`, `node-worker` and `iframe` modes (throws in `wasm`): install a
499
+ * global `fetch` in the sandbox that sends each request to `network.fetch`
500
+ * on the host, through the gated `fetch` capability (andbox#39). http(s)
501
+ * only, and only to the hosts `network.allowedHosts` allows (required,
502
+ * andbox#43); `credentials` is the host's choice. Other network globals stay
503
+ * locked in worker modes; the platform `import()` operator is not affected.
504
+ * Unset (default): worker modes have no `fetch`. Conflicts with a
505
+ * capability named `fetch`.
506
+ */
507
+ network?: SandboxNetworkOptions;
415
508
  }
416
509
 
417
510
  /** Options for the built-in Node worker_threads mode (`nodeWorker`). */
@@ -46,3 +46,337 @@ export function createNetworkFetch(allowedHosts, fetchFn) {
46
46
  return response;
47
47
  };
48
48
  }
49
+
50
+ // ── createSandbox({ network }) (andbox#39) ──
51
+
52
+ const NETWORK_KEYS = new Set(['fetch', 'allowedHosts', 'credentials']);
53
+ const CREDENTIALS = ['omit', 'same-origin', 'include'];
54
+ const REDIRECTS = ['follow', 'error', 'manual'];
55
+ const METHOD_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
56
+ /** Response header names the sandbox never sees (a browser's fetch hides them too). */
57
+ const HIDDEN_RESPONSE_HEADERS = new Set(['set-cookie', 'set-cookie2']);
58
+
59
+ function decodeBase64(text) {
60
+ const binary = atob(text);
61
+ const bytes = new Uint8Array(binary.length);
62
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
63
+ return bytes;
64
+ }
65
+
66
+ function headerPairs(headers) {
67
+ const out = [];
68
+ for (const [name, value] of headers) {
69
+ if (!HIDDEN_RESPONSE_HEADERS.has(name.toLowerCase())) out.push([name, value]);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ async function bodyToArrayBuffer(body) {
75
+ if (body == null) return null;
76
+ if (typeof body === 'string') return new TextEncoder().encode(body).buffer;
77
+ if (body instanceof ArrayBuffer) return body;
78
+ if (ArrayBuffer.isView(body)) return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
79
+ if (typeof body.arrayBuffer === 'function') return body.arrayBuffer(); // Blob
80
+ throw new TypeError('network.fetch: a returned body must be a string, ArrayBuffer, typed array or Blob');
81
+ }
82
+
83
+ /**
84
+ * Turn what the host function returned (a `Response`, or a plain
85
+ * `{ status, statusText, headers, body, url, redirected }`) into the reply the
86
+ * sandbox rebuilds its `Response` from.
87
+ */
88
+ async function toWireResponse(res, requestURL) {
89
+ if (res === null || typeof res !== 'object') {
90
+ throw new TypeError('network.fetch must return a Response or { status, statusText, headers, body }');
91
+ }
92
+ const isResponse = typeof res.arrayBuffer === 'function' && res.headers && typeof res.headers.get === 'function';
93
+ if (isResponse && (res.type === 'opaqueredirect' || res.type === 'error' || res.type === 'opaque')) {
94
+ throw new TypeError(`the host's fetch returned an unreadable '${res.type}' response`);
95
+ }
96
+ const status = res.status ?? 200;
97
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
98
+ throw new TypeError(`the host's fetch returned status ${String(status)}; a response needs 200-599`);
99
+ }
100
+ return {
101
+ status,
102
+ statusText: typeof res.statusText === 'string' ? res.statusText : '',
103
+ headers: headerPairs(isResponse ? res.headers : new Headers(res.headers ?? undefined)),
104
+ body: isResponse ? await res.arrayBuffer() : await bodyToArrayBuffer(res.body),
105
+ url: typeof res.url === 'string' && res.url ? res.url : requestURL,
106
+ redirected: res.redirected === true,
107
+ };
108
+ }
109
+
110
+ // ── allowedHosts: required, three forms (andbox#43) ──
111
+
112
+ /** The explicit opt-in for "any http(s) host; my `fetch` is the whole policy". */
113
+ const ANY_HOST = '*';
114
+
115
+ const MAX_REDIRECTS = 20;
116
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
117
+ /** Request headers that describe the body; dropped when a redirect turns the request into a GET. */
118
+ const BODY_HEADERS = ['content-encoding', 'content-language', 'content-location', 'content-type'];
119
+
120
+ const ALLOWED_HOSTS_EXAMPLE =
121
+ " network: { allowedHosts: ['api.example.com'] } // only these hosts\n" +
122
+ ' network: { fetch, allowedHosts: (url) => policy.allows(url.host) } // decided per request\n' +
123
+ " network: { fetch, allowedHosts: '*' } // any host: your fetch is the whole policy";
124
+
125
+ const ENTRY_HINT =
126
+ "entries are hostnames without a scheme, port, path or wildcard, e.g. 'api.example.com', '127.0.0.1', '[::1]'";
127
+
128
+ /**
129
+ * Normalise one `allowedHosts` array entry to the form `URL#hostname` has
130
+ * (lowercase, punycode, canonical IPv4, bracketed IPv6), so it can be
131
+ * compared with a request URL's hostname. Throws on anything that would
132
+ * silently never match (a port, a scheme, a path, a wildcard).
133
+ */
134
+ function normalizeHostEntry(entry) {
135
+ if (typeof entry !== 'string' || entry.length === 0) {
136
+ throw new TypeError(`network.allowedHosts: ${ENTRY_HINT} (got ${JSON.stringify(entry)})`);
137
+ }
138
+ if (entry === ANY_HOST) {
139
+ throw new TypeError("network.allowedHosts: to allow any host pass the string '*' itself, not inside an array");
140
+ }
141
+ const bare = entry.startsWith('[') ? entry.replace(/^\[[^\]]*\]/, '') : entry;
142
+ if (/[/?#@\s*\\]/.test(entry) || bare.includes(':')) {
143
+ throw new TypeError(`network.allowedHosts: ${ENTRY_HINT} (got '${entry}')`);
144
+ }
145
+ let hostname;
146
+ try {
147
+ hostname = new URL(`http://${entry}/`).hostname;
148
+ } catch {
149
+ throw new TypeError(`network.allowedHosts: '${entry}' is not a valid hostname; ${ENTRY_HINT}`);
150
+ }
151
+ return hostname;
152
+ }
153
+
154
+ /**
155
+ * Validate `createSandbox({ network })` without building anything, so
156
+ * `createSandbox()` can refuse a bad option before it starts a Worker.
157
+ *
158
+ * @returns {{ hostFetch?: Function, allowedHosts: string[] | '*' | ((url: URL) => unknown), credentials: RequestCredentials }}
159
+ */
160
+ export function validateNetworkOptions(network) {
161
+ if (network === null || typeof network !== 'object' || Array.isArray(network)) {
162
+ throw new TypeError('network must be an object: { allowedHosts, fetch?, credentials? }');
163
+ }
164
+ for (const key of Object.keys(network)) {
165
+ if (!NETWORK_KEYS.has(key)) {
166
+ throw new TypeError(`network.${key} is not a known option (expected allowedHosts, fetch, credentials)`);
167
+ }
168
+ }
169
+ const { fetch: hostFetch, allowedHosts, credentials = 'omit' } = network;
170
+ if (hostFetch !== undefined && typeof hostFetch !== 'function') {
171
+ throw new TypeError('network.fetch must be a function (url, init) => Response');
172
+ }
173
+ if (allowedHosts === undefined) {
174
+ throw new TypeError(
175
+ 'network.allowedHosts is required: the sandbox gets no network unless you say which hosts it may reach. ' +
176
+ `For example:\n${ALLOWED_HOSTS_EXAMPLE}`
177
+ );
178
+ }
179
+ let hosts;
180
+ if (allowedHosts === ANY_HOST || typeof allowedHosts === 'function') {
181
+ hosts = allowedHosts;
182
+ } else if (Array.isArray(allowedHosts)) {
183
+ if (allowedHosts.length === 0) {
184
+ throw new TypeError(
185
+ 'network.allowedHosts must list at least one host; to give the sandbox no network, omit `network`. ' +
186
+ `For example:\n${ALLOWED_HOSTS_EXAMPLE}`
187
+ );
188
+ }
189
+ hosts = [...new Set(allowedHosts.map(normalizeHostEntry))];
190
+ } else {
191
+ throw new TypeError(
192
+ "network.allowedHosts must be an array of hostnames, a function (url: URL) => boolean, or '*' " +
193
+ `(got ${typeof allowedHosts === 'string' ? `'${allowedHosts}'` : typeof allowedHosts}). For example:\n${ALLOWED_HOSTS_EXAMPLE}`
194
+ );
195
+ }
196
+ if (!CREDENTIALS.includes(credentials)) {
197
+ throw new TypeError(`network.credentials must be one of ${CREDENTIALS.map((c) => `'${c}'`).join(', ')}`);
198
+ }
199
+ return { hostFetch, allowedHosts: hosts, credentials };
200
+ }
201
+
202
+ /** The host's fetch, or the platform's, called without a `this`. */
203
+ function resolveFetch(hostFetch) {
204
+ if (hostFetch) return (url, init) => hostFetch(url, init);
205
+ return (url, init) => {
206
+ const platformFetch = globalThis.fetch;
207
+ if (typeof platformFetch !== 'function') throw new Error('fetch is not available');
208
+ return platformFetch.call(globalThis, url, init);
209
+ };
210
+ }
211
+
212
+ async function askPolicy(policy, href) {
213
+ // A fresh URL each time: the policy cannot change the URL andbox requests.
214
+ const verdict = await policy(new URL(href));
215
+ if (verdict !== true) {
216
+ throw new Error(`Network access denied: ${new URL(href).host} is not allowed by network.allowedHosts`);
217
+ }
218
+ }
219
+
220
+ function responseHeaders(res) {
221
+ if (res?.headers && typeof res.headers.get === 'function') return res.headers;
222
+ try {
223
+ return new Headers(res?.headers ?? undefined);
224
+ } catch {
225
+ return new Headers();
226
+ }
227
+ }
228
+
229
+ function discardBody(res) {
230
+ try {
231
+ res?.body?.cancel?.().catch(() => {});
232
+ } catch {
233
+ // nothing to release
234
+ }
235
+ }
236
+
237
+ /**
238
+ * `allowedHosts` as a function: ask it about every URL andbox is about to
239
+ * request, the first one and every redirect hop. Redirects are followed by
240
+ * andbox itself (`redirect: 'manual'` underneath), re-asking the function for
241
+ * each `Location`, with the Fetch standard's method/body rewriting and
242
+ * `Authorization` dropped on a cross-origin hop. Where the platform hides the
243
+ * target (a browser's `opaqueredirect`), the request fails closed.
244
+ */
245
+ function createPolicyFetch(policy, hostFetch) {
246
+ const send = resolveFetch(hostFetch);
247
+ return async function policyFetch(url, init) {
248
+ const { body: firstBody, headers: firstHeaders, method: firstMethod, ...rest } = init;
249
+ const mode = init.redirect ?? 'follow';
250
+ let href = url;
251
+ let method = firstMethod;
252
+ let headers = new Headers(firstHeaders);
253
+ let body = firstBody;
254
+ for (let hop = 0; ; hop++) {
255
+ await askPolicy(policy, href);
256
+ const res = await send(href, {
257
+ ...rest,
258
+ method,
259
+ headers,
260
+ ...(body !== undefined ? { body } : {}),
261
+ redirect: 'manual',
262
+ });
263
+ if (mode === 'manual') return res;
264
+ if (res?.type === 'opaqueredirect') {
265
+ throw new Error(
266
+ `Network access denied: ${new URL(href).host} answered with a redirect whose target this platform's fetch hides, ` +
267
+ "so network.allowedHosts cannot check it; pass a network.fetch that returns the redirect response, or use allowedHosts: '*'"
268
+ );
269
+ }
270
+ const location = REDIRECT_STATUSES.has(res?.status) ? responseHeaders(res).get('location') : null;
271
+ if (location === null) {
272
+ if (hop === 0) return res;
273
+ return { status: res.status, statusText: res.statusText, headers: responseHeaders(res), body: await readBody(res), url: href, redirected: true };
274
+ }
275
+ discardBody(res);
276
+ if (mode === 'error') throw new Error(`fetch: ${new URL(href).host} redirected and the request's redirect mode is 'error'`);
277
+ if (hop + 1 > MAX_REDIRECTS) throw new Error(`fetch: more than ${MAX_REDIRECTS} redirects`);
278
+ let next;
279
+ try {
280
+ next = new URL(location, href);
281
+ } catch {
282
+ throw new Error(`fetch: ${new URL(href).host} redirected to an invalid URL`);
283
+ }
284
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
285
+ throw new Error(`Network access denied: ${new URL(href).host} redirected to a ${next.protocol} URL`);
286
+ }
287
+ const status = res.status;
288
+ if ((status === 303 && method !== 'GET' && method !== 'HEAD') || ((status === 301 || status === 302) && method === 'POST')) {
289
+ method = 'GET';
290
+ body = undefined;
291
+ headers = new Headers(headers);
292
+ for (const name of BODY_HEADERS) headers.delete(name);
293
+ }
294
+ if (next.origin !== new URL(href).origin) {
295
+ headers = new Headers(headers);
296
+ headers.delete('authorization');
297
+ }
298
+ href = next.href;
299
+ }
300
+ };
301
+ }
302
+
303
+ async function readBody(res) {
304
+ if (res == null) return null;
305
+ if (typeof res.arrayBuffer === 'function') return res.arrayBuffer();
306
+ return res.body ?? null;
307
+ }
308
+
309
+ /**
310
+ * The host-side sender for a validated `network`: the policy in front of the
311
+ * host's (or the platform's) fetch.
312
+ */
313
+ function createNetworkSender({ hostFetch, allowedHosts }) {
314
+ if (allowedHosts === ANY_HOST) return resolveFetch(hostFetch);
315
+ if (typeof allowedHosts === 'function') return createPolicyFetch(allowedHosts, hostFetch);
316
+ return createNetworkFetch(allowedHosts, hostFetch);
317
+ }
318
+
319
+ /**
320
+ * Validate `createSandbox({ network })` and build the host-side `fetch`
321
+ * capability behind the sandbox's global `fetch` shim.
322
+ *
323
+ * Everything that arrives from the sandbox is treated as untrusted input
324
+ * (evaluated code can also call `host.call('fetch', url, init)` directly):
325
+ * the URL must be http(s) and pass `allowedHosts` before the host's `fetch`
326
+ * is called, only method/headers/body/redirect are taken from the request,
327
+ * and `credentials` and `signal` are always set by the host.
328
+ *
329
+ * @param {{ allowedHosts: string[] | '*' | ((url: URL) => boolean | Promise<boolean>), fetch?: Function, credentials?: RequestCredentials }} network
330
+ * @returns {(this: { signal?: AbortSignal }, url: unknown, init?: unknown) => Promise<object>}
331
+ */
332
+ export function createFetchCapability(network) {
333
+ const options = validateNetworkOptions(network);
334
+ const { credentials } = options;
335
+ const send = createNetworkSender(options);
336
+
337
+ return async function fetchCapability(url, init) {
338
+ if (typeof url !== 'string') throw new TypeError('fetch: the URL must be a string');
339
+ let target;
340
+ try {
341
+ target = new URL(url);
342
+ } catch {
343
+ throw new TypeError(`fetch: invalid URL: ${url}`);
344
+ }
345
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
346
+ throw new TypeError(`fetch: only http(s) URLs are allowed (got ${target.protocol})`);
347
+ }
348
+ const req = init === undefined || init === null ? {} : init;
349
+ if (typeof req !== 'object') throw new TypeError('fetch: init must be an object');
350
+
351
+ const method = req.method ?? 'GET';
352
+ if (typeof method !== 'string' || !METHOD_TOKEN.test(method)) throw new TypeError('fetch: invalid method');
353
+ const pairs = req.headers ?? [];
354
+ if (!Array.isArray(pairs) || !pairs.every((p) => Array.isArray(p) && p.length === 2 && p.every((v) => typeof v === 'string'))) {
355
+ throw new TypeError('fetch: headers must be an array of [name, value] string pairs');
356
+ }
357
+ let body;
358
+ if (req.body !== undefined && req.bodyBase64 !== undefined) throw new TypeError('fetch: body and bodyBase64 are exclusive');
359
+ if (req.body !== undefined) {
360
+ if (typeof req.body !== 'string') throw new TypeError('fetch: body must be a string (binary goes in bodyBase64)');
361
+ body = req.body;
362
+ } else if (req.bodyBase64 !== undefined) {
363
+ if (typeof req.bodyBase64 !== 'string') throw new TypeError('fetch: bodyBase64 must be a string');
364
+ body = decodeBase64(req.bodyBase64);
365
+ }
366
+ if (req.redirect !== undefined && !REDIRECTS.includes(req.redirect)) throw new TypeError('fetch: invalid redirect mode');
367
+
368
+ const hostInit = {
369
+ method,
370
+ headers: new Headers(pairs),
371
+ ...(body !== undefined ? { body } : {}),
372
+ ...(req.redirect !== undefined ? { redirect: req.redirect } : {}),
373
+ // Always the host's choice; nothing from the sandbox can change it.
374
+ credentials,
375
+ ...(this?.signal ? { signal: this.signal } : {}),
376
+ };
377
+ // Called without a `this`, so the platform's own fetch can be passed as
378
+ // network.fetch (it throws "Illegal invocation" on any other receiver).
379
+ const res = await send(target.href, hostInit);
380
+ return toWireResponse(res, target.href);
381
+ };
382
+ }
package/src/sandbox.mjs CHANGED
@@ -18,6 +18,7 @@ import { makeDeferred, makeTimeoutError, makeAbortError } from './deferred.mjs';
18
18
  import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
19
19
  import { isNodeRuntime, createNodeWorkerFactory } from './node-worker.mjs';
20
20
  import { normalizeIframeOptions, createIframeFactory, makeIframeRuntimeSource } from './iframe-host.mjs';
21
+ import { createFetchCapability, validateNetworkOptions } from './network-policy.mjs';
21
22
 
22
23
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
23
24
 
@@ -312,9 +313,12 @@ async function createServiceWorkerSandbox(options = {}) {
312
313
  * @property {string[]} [iframeSandbox] - mode: 'iframe': extra sandbox tokens ('allow-same-origin' needs dangerouslyAllowSameOrigin)
313
314
  * @property {boolean} [dangerouslyAllowSameOrigin] - mode: 'iframe': permit 'allow-same-origin' (removes the origin boundary)
314
315
  * @property {(iframe: HTMLIFrameElement) => void} [onFrame] - mode: 'iframe': called with every new frame before it is attached
316
+ * @property {{ allowedHosts: string[] | '*' | ((url: URL) => boolean | Promise<boolean>), fetch?: Function, credentials?: RequestCredentials }} [network] - worker, node-worker and iframe modes: install a global `fetch` in the sandbox that goes through the host (andbox#39); `allowedHosts` is required (andbox#43)
315
317
  */
316
318
 
317
319
  const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'iframe', 'inline', 'data-uri', 'service-worker'];
320
+ /** Modes whose runtime can install the host-backed `fetch` (`network`, andbox#39). */
321
+ const NETWORK_MODES = ['worker', 'node-worker', 'iframe'];
318
322
 
319
323
  /**
320
324
  * Create a new sandboxed runtime.
@@ -346,6 +350,15 @@ export function createSandbox(options = {}) {
346
350
  `Supported modes: ${SUPPORTED_MODES.map((m) => `'${m}'`).join(', ')}.`
347
351
  );
348
352
  }
353
+ if (options.network !== undefined && !NETWORK_MODES.includes(mode)) {
354
+ // An option that silently does nothing is worse than an error.
355
+ throw new Error(
356
+ `The network option applies to ${NETWORK_MODES.map((m) => `mode: '${m}'`).join(', ')}, not mode: '${mode}'.` +
357
+ (mode === 'wasm' ? " The wasm engine has no fetch; expose a capability and call it with host.call()." : '')
358
+ );
359
+ }
360
+ // Refuse a bad `network` (no allowedHosts, andbox#43) before starting anything.
361
+ if (options.network !== undefined) validateNetworkOptions(options.network);
349
362
  if (mode === 'inline') return createInlineSandbox(options);
350
363
  if (mode === 'data-uri') return createDataUriSandbox(options);
351
364
  if (mode === 'service-worker') return createServiceWorkerSandbox(options);
@@ -437,6 +450,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
437
450
  nodeWorker,
438
451
  unref = false,
439
452
  allowedImportHosts,
453
+ network,
440
454
  } = options;
441
455
 
442
456
  // undefined = unset: remote imports allowed. An array (even empty) restricts.
@@ -489,8 +503,22 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
489
503
  wasmConfig = await resolveWasmConfig(options, baseURL, usingNode);
490
504
  }
491
505
 
506
+ // `network` (andbox#39): the sandbox's global fetch calls the host's `fetch`
507
+ // capability, which goes through the same gate and `policy` as the others.
508
+ let gatedCapabilities = capabilities;
509
+ if (network !== undefined) {
510
+ if (Object.prototype.hasOwnProperty.call(capabilities, 'fetch')) {
511
+ throw new Error(
512
+ "capabilities.fetch and the network option both define the 'fetch' capability; " +
513
+ 'pass your function as network.fetch instead.'
514
+ );
515
+ }
516
+ gatedCapabilities = { ...capabilities, fetch: createFetchCapability(network) };
517
+ }
518
+ const networkFetch = network !== undefined;
519
+
492
520
  // Gate capabilities with rate limits
493
- const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(capabilities, policy);
521
+ const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(gatedCapabilities, policy);
494
522
 
495
523
  // Console handler — mutable so evaluate() can swap per-call
496
524
  let activeConsoleHandler = onConsole || null;
@@ -524,7 +552,9 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
524
552
 
525
553
  function createWorker() {
526
554
  workerAbort = new AbortController();
527
- const source = isWasm ? makeWasmWorkerSource() : isIframe ? makeIframeRuntimeSource() : makeWorkerSource();
555
+ const source = isWasm
556
+ ? makeWasmWorkerSource()
557
+ : isIframe ? makeIframeRuntimeSource({ networkFetch }) : makeWorkerSource({ networkFetch });
528
558
  if (workerFactory) {
529
559
  worker = workerFactory(source);
530
560
  attachWorkerHandlers();
@@ -7,6 +7,117 @@
7
7
  * host capabilities.
8
8
  */
9
9
 
10
+ /**
11
+ * The in-sandbox half of `createSandbox({ network })` (andbox#39): installs a
12
+ * global `fetch` that serializes the request, sends it to the host's `fetch`
13
+ * capability and rebuilds a real `Response` from the reply.
14
+ *
15
+ * Stringified into the runtime with `toString()`, so it must not reference
16
+ * anything outside its own body; the runtime passes in what it needs.
17
+ *
18
+ * @param {(name: string, args: unknown[]) => Promise<any>} callHost
19
+ * @param {() => string} getBaseURL resolves relative URLs (the Worker's own
20
+ * location is a `blob:` URL, which relative URLs cannot resolve against)
21
+ */
22
+ function installNetworkFetch(callHost, getBaseURL) {
23
+ // Captured once, before evaluated code runs, so later changes to these
24
+ // globals do not change what the shim does.
25
+ const G = globalThis;
26
+ const RequestCtor = G.Request;
27
+ const ResponseCtor = G.Response;
28
+ const URLCtor = G.URL;
29
+ const DOMExceptionCtor = G.DOMException;
30
+ const toBase64 = G.btoa.bind(G);
31
+ const fromCharCode = String.fromCharCode;
32
+ const defineProperty = Object.defineProperty;
33
+ const NULL_BODY_STATUS = [101, 103, 204, 205, 304];
34
+
35
+ function abortReason(signal) {
36
+ if (signal.reason !== undefined) return signal.reason;
37
+ return typeof DOMExceptionCtor === 'function'
38
+ ? new DOMExceptionCtor('This operation was aborted', 'AbortError')
39
+ : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' });
40
+ }
41
+
42
+ function bytesToBase64(buffer) {
43
+ const bytes = new Uint8Array(buffer);
44
+ let binary = '';
45
+ for (let i = 0; i < bytes.length; i += 0x8000) {
46
+ binary += fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
47
+ }
48
+ return toBase64(binary);
49
+ }
50
+
51
+ async function fetch(input, init) {
52
+ const options = init == null ? {} : init;
53
+ const isRequest = input instanceof RequestCtor;
54
+ const signal = options.signal != null ? options.signal : isRequest ? input.signal : null;
55
+ if (signal && signal.aborted) throw abortReason(signal);
56
+
57
+ const raw = isRequest ? input.url : String(input);
58
+ let url;
59
+ try {
60
+ url = new URLCtor(raw, getBaseURL());
61
+ } catch {
62
+ throw new TypeError(`fetch: invalid URL: ${raw}`);
63
+ }
64
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
65
+ throw new TypeError(`fetch: only http(s) URLs are allowed (got ${url.protocol})`);
66
+ }
67
+
68
+ // Let the platform normalise method, headers and body (FormData,
69
+ // URLSearchParams, Blob, typed arrays, streams). Only these fields reach
70
+ // the host; credentials, mode, cache, referrer and the rest are the
71
+ // host's decision, not the sandbox's.
72
+ const picked = {};
73
+ for (const k of ['method', 'headers', 'body', 'redirect', 'duplex']) {
74
+ if (options[k] !== undefined) picked[k] = options[k];
75
+ }
76
+ const request = new RequestCtor(isRequest ? input : url.href, picked);
77
+ const wire = { method: request.method, headers: [...request.headers], redirect: request.redirect };
78
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
79
+ if (typeof options.body === 'string') {
80
+ wire.body = options.body;
81
+ } else {
82
+ // Binary travels as base64 so the capability gate's argument-size
83
+ // limits (policy.capabilities.fetch.maxArgBytes) count it.
84
+ const buffer = await request.arrayBuffer();
85
+ if (buffer.byteLength > 0) wire.bodyBase64 = bytesToBase64(buffer);
86
+ }
87
+ }
88
+
89
+ const pending = callHost('fetch', [url.href, wire]);
90
+ let reply;
91
+ try {
92
+ reply = signal
93
+ ? await new Promise((resolve, reject) => {
94
+ const onAbort = () => reject(abortReason(signal));
95
+ signal.addEventListener('abort', onAbort, { once: true });
96
+ pending.then(
97
+ (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
98
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e); },
99
+ );
100
+ })
101
+ : await pending;
102
+ } catch (e) {
103
+ if (signal && signal.aborted) throw e;
104
+ throw new TypeError(`fetch failed: ${e && e.message ? e.message : String(e)}`, { cause: e });
105
+ }
106
+
107
+ const response = new ResponseCtor(NULL_BODY_STATUS.includes(reply.status) ? null : reply.body, {
108
+ status: reply.status,
109
+ statusText: reply.statusText,
110
+ headers: reply.headers,
111
+ });
112
+ defineProperty(response, 'url', { value: reply.url, enumerable: true });
113
+ defineProperty(response, 'redirected', { value: reply.redirected === true, enumerable: true });
114
+ return response;
115
+ }
116
+
117
+ try { delete G.fetch; } catch {}
118
+ defineProperty(G, 'fetch', { value: fetch, writable: true, configurable: true, enumerable: false });
119
+ }
120
+
10
121
  /**
11
122
  * Generate the Worker source code as a string.
12
123
  *
@@ -23,10 +134,14 @@
23
134
  * - `capabilityCall`: RPC request to host capability
24
135
  * - `console`: Forwarded console output
25
136
  *
137
+ * @param {{ networkFetch?: boolean }} [options]
138
+ * `networkFetch` (default false) installs a global `fetch` that forwards
139
+ * every request to the host's `fetch` capability (`createSandbox({ network })`,
140
+ * andbox#39) instead of leaving `fetch` locked.
26
141
  * @returns {string} The Worker script source code.
27
142
  */
28
- export function makeWorkerSource() {
29
- return makeRuntimeSource({ lockdown: true });
143
+ export function makeWorkerSource({ networkFetch = false } = {}) {
144
+ return makeRuntimeSource({ lockdown: true, networkFetch });
30
145
  }
31
146
 
32
147
  /**
@@ -36,17 +151,21 @@ export function makeWorkerSource() {
36
151
  * `postMessage`, `close` and an `onmessage` setter: the real Worker global
37
152
  * scope, or (iframe mode) a wrapper around a MessagePort the frame was handed.
38
153
  *
39
- * @param {{ lockdown?: boolean }} [options]
154
+ * @param {{ lockdown?: boolean, networkFetch?: boolean }} [options]
40
155
  * `lockdown` (default true) deletes and shadows the Worker's ambient
41
156
  * network/worker globals (andbox#10). The iframe runtime turns it off: there
42
157
  * the browser's opaque-origin boundary is the isolation, and evaluated code is
43
158
  * meant to have its frame's `window`/`document`.
159
+ * `networkFetch` (default false) replaces the global `fetch` with a shim that
160
+ * sends each request to the host's `fetch` capability (andbox#39). With
161
+ * `lockdown` it is the only `fetch` the code can reach; everything else on
162
+ * the lockdown list stays removed.
44
163
  * @returns {string}
45
164
  */
46
- export function makeRuntimeSource({ lockdown = true } = {}) {
165
+ export function makeRuntimeSource({ lockdown = true, networkFetch = false } = {}) {
47
166
  const lockedGlobals = lockdown
48
167
  ? `[
49
- 'fetch', 'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource',
168
+ ${networkFetch ? '' : "'fetch', "}'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource',
50
169
  'Worker', 'SharedWorker', 'importScripts', 'indexedDB', 'caches', 'BroadcastChannel',
51
170
  'postMessage', 'self',
52
171
  ]`
@@ -81,7 +200,11 @@ for (const k of LOCKED_GLOBALS) {
81
200
  try { Object.defineProperty(globalThis, k, { value: undefined, writable: false, configurable: false }); } catch {}
82
201
  }
83
202
  }
84
- // Names shadowed lexically for evaluated code as well (covers environments
203
+ ${networkFetch ? `// ── Host-backed fetch (andbox#39) ──
204
+ // Replaces the global fetch: every request goes to the host's
205
+ // gated \`fetch\` capability, which decides policy and credentials.
206
+ (${installNetworkFetch.toString()})((name, args) => callCapability(name, args), () => baseURL);
207
+ ` : ''}// Names shadowed lexically for evaluated code as well (covers environments
85
208
  // where a global could not be deleted).
86
209
  const SHADOWED = ${shadowed};
87
210