@johnhenry/andbox 0.1.3 → 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
@@ -294,19 +294,19 @@ Use `mode: 'wasm'` (fuel and deadline inside the engine) or `worker` mode for co
294
294
 
295
295
  ## Mediated network: `network`
296
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:
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
298
 
299
299
  ```js
300
300
  import { createSandbox } from '@johnhenry/andbox';
301
301
 
302
302
  const sandbox = await createSandbox({
303
303
  network: {
304
- // Runs on the host for every request the sandbox makes. Same signature as fetch.
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.
305
306
  async fetch(url, init) {
306
307
  console.log(init.method, url); // you see every request
307
308
  return fetch(url, { ...init, referrerPolicy: 'no-referrer' }); // init.credentials is 'omit'
308
309
  },
309
- // allowedHosts: ['api.example.com'], // optional: createNetworkFetch() allowlist in front of it
310
310
  },
311
311
  policy: { capabilities: { fetch: { maxCalls: 100 } } }, // the usual gate applies
312
312
  });
@@ -320,14 +320,39 @@ await sandbox.evaluate(`
320
320
 
321
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
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`, 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: { 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.
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
324
 
325
325
  | `network` field | Type | Default | Description |
326
326
  |---|---|---|---|
327
- | `fetch` | `(url, init) => Response \| { status, headers, body, ... }` | -- | Host function for every sandbox request. Required unless `allowedHosts` is given. |
328
- | `allowedHosts` | `string[]` | -- | Put [`createNetworkFetch(allowedHosts, fetch)`](#createnetworkfetchallowedhosts-fetchfn) in front: other hosts and any redirect are refused. Without `fetch` it wraps the host's own `fetch`. Must not be empty. |
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
329
  | `credentials` | `'omit' \| 'same-origin' \| 'include'` | `'omit'` | What the host passes as `init.credentials`. The sandbox cannot change it. |
330
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
+
331
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.
332
357
 
333
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).
@@ -360,7 +385,7 @@ Creates a new sandboxed runtime. Returns a promise (Worker, wasm and iframe mode
360
385
  | `iframeSandbox` | `string[]` | `[]` | `mode: 'iframe'`: extra sandbox tokens (`allow-scripts` is always set); `'allow-same-origin'` throws unless `dangerouslyAllowSameOrigin` |
361
386
  | `dangerouslyAllowSameOrigin` | `boolean` | `false` | `mode: 'iframe'`: permit `'allow-same-origin'`, which removes the origin boundary |
362
387
  | `onFrame` | `(iframe) => void` | -- | `mode: 'iframe'`: called with every new frame (first and after each restart) before it is attached |
363
- | `network` | `{ fetch?, allowedHosts?, 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). **Unset: no `fetch` in worker modes** (unchanged). See [Mediated network](#mediated-network-network). |
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). |
364
389
 
365
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)).
366
391
 
@@ -439,7 +464,7 @@ registry.dispose(); // revokes blob URLs / drop
439
464
 
440
465
  ### `createNetworkFetch(allowedHosts?, fetchFn?)`
441
466
 
442
- 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`.
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)).
443
468
 
444
469
  ```js
445
470
  import { createNetworkFetch } from '@johnhenry/andbox';
@@ -541,7 +566,8 @@ andbox is **not** a boundary against code that is actively trying to escape it.
541
566
 
542
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).
543
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).
544
- - **`network` narrows `fetch` to what your host function allows; it does not close the other routes out.** With `network` set, the sandbox's global `fetch` is a shim: each request goes to your function through the gated `fetch` capability, http(s) only, 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): validate URLs there, or use `allowedHosts`, and do not forward the sandbox's headers to hosts that trust them blindly. See [Mediated network](#mediated-network-network) and [andbox#39](https://github.com/johnhenry/andbox/issues/39).
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).
545
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).
546
572
  - **`mode: 'iframe'`: the origin boundary is the whole guarantee.** Still yours:
547
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.
@@ -596,15 +622,6 @@ code-based tool execution.
596
622
  is the source of truth for what that capability gate does and does not
597
623
  guarantee -- the middleware's Security model section points back here
598
624
  rather than repeating it.
599
- - **[`@johnhenry/prism`](https://github.com/johnhenry/prism)** -- a live
600
- HTTP request inspector/proxy whose custom script-route feature runs
601
- user-provided route handlers via `createSandbox({ mode: 'inline' })`.
602
- Deliberately uses `inline` mode, not the default `worker` mode: the
603
- handler needs a live `Request` object (with its body stream) directly in
604
- scope, which can't cross a Worker's structured-clone boundary, and the
605
- feature's predecessor (a package called `vimble`) never provided real
606
- isolation either -- `inline`'s explicit "no isolation, code you already
607
- trust" framing is the honest match, not a downgrade from what came before.
608
625
 
609
626
  ## License
610
627
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.1.3",
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",
package/src/index.d.ts CHANGED
@@ -251,23 +251,46 @@ export interface SandboxFetchReply {
251
251
  redirected?: boolean;
252
252
  }
253
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
+
254
262
  /** `createSandbox({ network })`: a host-backed global `fetch` inside the sandbox. */
255
263
  export interface SandboxNetworkOptions {
256
264
  /**
257
- * Called on the host for every request the sandbox's `fetch` makes, through
258
- * the gated `fetch` capability (`policy.capabilities.fetch` applies). The
259
- * URL is always absolute http(s). Return a `Response` or a plain reply.
260
- * Called without a `this`, so the platform `fetch` itself can be passed.
261
- * Required unless `allowedHosts` is given.
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).
262
291
  */
263
292
  fetch?: (url: string, init: SandboxFetchInit) =>
264
293
  Response | SandboxFetchReply | Promise<Response | SandboxFetchReply>;
265
- /**
266
- * Put `createNetworkFetch(allowedHosts, fetch)` in front: other hosts and
267
- * any redirect are refused. Without `fetch` it wraps the host's global
268
- * `fetch`. Must not be empty.
269
- */
270
- allowedHosts?: string[];
271
294
  /** `init.credentials` for every request. Default `'omit'`. */
272
295
  credentials?: RequestCredentials;
273
296
  }
@@ -475,7 +498,8 @@ export interface SandboxOptions {
475
498
  * `worker`, `node-worker` and `iframe` modes (throws in `wasm`): install a
476
499
  * global `fetch` in the sandbox that sends each request to `network.fetch`
477
500
  * on the host, through the gated `fetch` capability (andbox#39). http(s)
478
- * only; `credentials` is the host's choice. Other network globals stay
501
+ * only, and only to the hosts `network.allowedHosts` allows (required,
502
+ * andbox#43); `credentials` is the host's choice. Other network globals stay
479
503
  * locked in worker modes; the platform `import()` operator is not affected.
480
504
  * Unset (default): worker modes have no `fetch`. Conflicts with a
481
505
  * capability named `fetch`.
@@ -107,47 +107,232 @@ async function toWireResponse(res, requestURL) {
107
107
  };
108
108
  }
109
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
+
110
128
  /**
111
- * Validate `createSandbox({ network })` and build the host-side `fetch`
112
- * capability behind the sandbox's global `fetch` shim.
113
- *
114
- * Everything that arrives from the sandbox is treated as untrusted input
115
- * (evaluated code can also call `host.call('fetch', url, init)` directly):
116
- * the URL must be http(s), only method/headers/body/redirect are taken from
117
- * the request, and `credentials` and `signal` are always set by the host.
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.
118
157
  *
119
- * @param {{ fetch?: Function, allowedHosts?: string[], credentials?: RequestCredentials }} network
120
- * @returns {(this: { signal?: AbortSignal }, url: unknown, init?: unknown) => Promise<object>}
158
+ * @returns {{ hostFetch?: Function, allowedHosts: string[] | '*' | ((url: URL) => unknown), credentials: RequestCredentials }}
121
159
  */
122
- export function createFetchCapability(network) {
160
+ export function validateNetworkOptions(network) {
123
161
  if (network === null || typeof network !== 'object' || Array.isArray(network)) {
124
- throw new TypeError('network must be an object: { fetch?, allowedHosts?, credentials? }');
162
+ throw new TypeError('network must be an object: { allowedHosts, fetch?, credentials? }');
125
163
  }
126
164
  for (const key of Object.keys(network)) {
127
165
  if (!NETWORK_KEYS.has(key)) {
128
- throw new TypeError(`network.${key} is not a known option (expected fetch, allowedHosts, credentials)`);
166
+ throw new TypeError(`network.${key} is not a known option (expected allowedHosts, fetch, credentials)`);
129
167
  }
130
168
  }
131
169
  const { fetch: hostFetch, allowedHosts, credentials = 'omit' } = network;
132
170
  if (hostFetch !== undefined && typeof hostFetch !== 'function') {
133
171
  throw new TypeError('network.fetch must be a function (url, init) => Response');
134
172
  }
135
- if (allowedHosts !== undefined) {
136
- if (!Array.isArray(allowedHosts) || !allowedHosts.every((h) => typeof h === 'string' && h.length > 0)) {
137
- throw new TypeError('network.allowedHosts must be an array of hostname strings');
138
- }
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)) {
139
183
  if (allowedHosts.length === 0) {
140
- throw new TypeError('network.allowedHosts must list at least one host; to give the sandbox no network, omit `network`');
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
+ );
141
188
  }
142
- }
143
- if (hostFetch === undefined && allowedHosts === undefined) {
144
- throw new TypeError('network needs `fetch` (a host function) and/or `allowedHosts`');
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
+ );
145
195
  }
146
196
  if (!CREDENTIALS.includes(credentials)) {
147
197
  throw new TypeError(`network.credentials must be one of ${CREDENTIALS.map((c) => `'${c}'`).join(', ')}`);
148
198
  }
199
+ return { hostFetch, allowedHosts: hosts, credentials };
200
+ }
149
201
 
150
- const send = allowedHosts ? createNetworkFetch(allowedHosts, hostFetch) : hostFetch;
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);
151
336
 
152
337
  return async function fetchCapability(url, init) {
153
338
  if (typeof url !== 'string') throw new TypeError('fetch: the URL must be a string');
package/src/sandbox.mjs CHANGED
@@ -18,7 +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 } from './network-policy.mjs';
21
+ import { createFetchCapability, validateNetworkOptions } from './network-policy.mjs';
22
22
 
23
23
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
24
24
 
@@ -313,7 +313,7 @@ async function createServiceWorkerSandbox(options = {}) {
313
313
  * @property {string[]} [iframeSandbox] - mode: 'iframe': extra sandbox tokens ('allow-same-origin' needs dangerouslyAllowSameOrigin)
314
314
  * @property {boolean} [dangerouslyAllowSameOrigin] - mode: 'iframe': permit 'allow-same-origin' (removes the origin boundary)
315
315
  * @property {(iframe: HTMLIFrameElement) => void} [onFrame] - mode: 'iframe': called with every new frame before it is attached
316
- * @property {{ fetch?: Function, allowedHosts?: string[], credentials?: RequestCredentials }} [network] - worker, node-worker and iframe modes: install a global `fetch` in the sandbox that goes through the host (andbox#39)
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)
317
317
  */
318
318
 
319
319
  const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'iframe', 'inline', 'data-uri', 'service-worker'];
@@ -357,6 +357,8 @@ export function createSandbox(options = {}) {
357
357
  (mode === 'wasm' ? " The wasm engine has no fetch; expose a capability and call it with host.call()." : '')
358
358
  );
359
359
  }
360
+ // Refuse a bad `network` (no allowedHosts, andbox#43) before starting anything.
361
+ if (options.network !== undefined) validateNetworkOptions(options.network);
360
362
  if (mode === 'inline') return createInlineSandbox(options);
361
363
  if (mode === 'data-uri') return createDataUriSandbox(options);
362
364
  if (mode === 'service-worker') return createServiceWorkerSandbox(options);