@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 +35 -18
- package/package.json +1 -1
- package/src/index.d.ts +36 -12
- package/src/network-policy.mjs +206 -21
- package/src/sandbox.mjs +4 -2
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
|
-
//
|
|
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
|
-
| `
|
|
328
|
-
| `
|
|
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?,
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
258
|
-
* the
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
|
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`.
|
package/src/network-policy.mjs
CHANGED
|
@@ -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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
* @
|
|
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
|
|
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?,
|
|
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
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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
|
-
|
|
144
|
-
throw new TypeError(
|
|
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
|
-
|
|
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 {{
|
|
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);
|