@johnhenry/andbox 0.1.2 → 0.1.3
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 +49 -6
- package/package.json +1 -1
- package/src/iframe-host.mjs +7 -3
- package/src/index.d.ts +70 -1
- package/src/network-policy.mjs +149 -0
- package/src/sandbox.mjs +30 -2
- package/src/worker-source.mjs +129 -6
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,46 @@ 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:
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
import { createSandbox } from '@johnhenry/andbox';
|
|
301
|
+
|
|
302
|
+
const sandbox = await createSandbox({
|
|
303
|
+
network: {
|
|
304
|
+
// Runs on the host for every request the sandbox makes. Same signature as fetch.
|
|
305
|
+
async fetch(url, init) {
|
|
306
|
+
console.log(init.method, url); // you see every request
|
|
307
|
+
return fetch(url, { ...init, referrerPolicy: 'no-referrer' }); // init.credentials is 'omit'
|
|
308
|
+
},
|
|
309
|
+
// allowedHosts: ['api.example.com'], // optional: createNetworkFetch() allowlist in front of it
|
|
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`, 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.
|
|
324
|
+
|
|
325
|
+
| `network` field | Type | Default | Description |
|
|
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. |
|
|
329
|
+
| `credentials` | `'omit' \| 'same-origin' \| 'include'` | `'omit'` | What the host passes as `init.credentials`. The sandbox cannot change it. |
|
|
330
|
+
|
|
331
|
+
**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
|
+
|
|
333
|
+
**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).
|
|
334
|
+
|
|
294
335
|
## API
|
|
295
336
|
|
|
296
337
|
### `createSandbox(options?)`
|
|
@@ -319,6 +360,7 @@ Creates a new sandboxed runtime. Returns a promise (Worker, wasm and iframe mode
|
|
|
319
360
|
| `iframeSandbox` | `string[]` | `[]` | `mode: 'iframe'`: extra sandbox tokens (`allow-scripts` is always set); `'allow-same-origin'` throws unless `dangerouslyAllowSameOrigin` |
|
|
320
361
|
| `dangerouslyAllowSameOrigin` | `boolean` | `false` | `mode: 'iframe'`: permit `'allow-same-origin'`, which removes the origin boundary |
|
|
321
362
|
| `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). |
|
|
322
364
|
|
|
323
365
|
**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
366
|
|
|
@@ -397,7 +439,7 @@ registry.dispose(); // revokes blob URLs / drop
|
|
|
397
439
|
|
|
398
440
|
### `createNetworkFetch(allowedHosts?, fetchFn?)`
|
|
399
441
|
|
|
400
|
-
Creates a fetch function that checks the request hostname against an allowlist before calling through.
|
|
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`.
|
|
401
443
|
|
|
402
444
|
```js
|
|
403
445
|
import { createNetworkFetch } from '@johnhenry/andbox';
|
|
@@ -415,9 +457,9 @@ Creates an async iterable stream for console output capture.
|
|
|
415
457
|
|
|
416
458
|
Promise and error utilities used internally, also available for consumers.
|
|
417
459
|
|
|
418
|
-
### `makeWorkerSource()`
|
|
460
|
+
### `makeWorkerSource(options?)`
|
|
419
461
|
|
|
420
|
-
Returns the Worker script source code as a string (useful for custom Worker setups).
|
|
462
|
+
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
463
|
|
|
422
464
|
### `createSandbox({ mode: 'service-worker', ... })`
|
|
423
465
|
|
|
@@ -497,8 +539,9 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
497
539
|
|
|
498
540
|
**What is still yours:**
|
|
499
541
|
|
|
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).
|
|
542
|
+
- **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
543
|
- **`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).
|
|
502
545
|
- **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
546
|
- **`mode: 'iframe'`: the origin boundary is the whole guarantee.** Still yours:
|
|
504
547
|
- **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 +562,7 @@ What each mode is built to stop, and what it is not. "Hostile" means code active
|
|
|
519
562
|
| | `worker` / `node-worker` | `wasm` | `iframe` |
|
|
520
563
|
|---|---|---|---|
|
|
521
564
|
| **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
|
|
565
|
+
| **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
566
|
| **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
567
|
| **`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
568
|
| **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. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/andbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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/iframe-host.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
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,62 @@ 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
|
+
/** `createSandbox({ network })`: a host-backed global `fetch` inside the sandbox. */
|
|
255
|
+
export interface SandboxNetworkOptions {
|
|
256
|
+
/**
|
|
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.
|
|
262
|
+
*/
|
|
263
|
+
fetch?: (url: string, init: SandboxFetchInit) =>
|
|
264
|
+
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
|
+
/** `init.credentials` for every request. Default `'omit'`. */
|
|
272
|
+
credentials?: RequestCredentials;
|
|
273
|
+
}
|
|
274
|
+
|
|
219
275
|
// ── stdio ──
|
|
220
276
|
|
|
221
277
|
/** An async iterable stdio stream with push/end controls. */
|
|
@@ -243,9 +299,12 @@ export declare function createStdio(): StdioStream;
|
|
|
243
299
|
* from the host, and sends configured, moduleDefined, result, capabilityCall,
|
|
244
300
|
* and console messages back.
|
|
245
301
|
*
|
|
302
|
+
* @param options.networkFetch Install the host-backed global `fetch` shim
|
|
303
|
+
* (what `createSandbox({ network })` uses). It calls the host's `fetch`
|
|
304
|
+
* capability. Default false: `fetch` is removed like the other network globals.
|
|
246
305
|
* @returns The complete Worker script source code as a string.
|
|
247
306
|
*/
|
|
248
|
-
export declare function makeWorkerSource(): string;
|
|
307
|
+
export declare function makeWorkerSource(options?: { networkFetch?: boolean }): string;
|
|
249
308
|
|
|
250
309
|
// ── service-worker-source ──
|
|
251
310
|
|
|
@@ -412,6 +471,16 @@ export interface SandboxOptions {
|
|
|
412
471
|
* wasm mode is unavailable.
|
|
413
472
|
*/
|
|
414
473
|
untrusted?: boolean;
|
|
474
|
+
/**
|
|
475
|
+
* `worker`, `node-worker` and `iframe` modes (throws in `wasm`): install a
|
|
476
|
+
* global `fetch` in the sandbox that sends each request to `network.fetch`
|
|
477
|
+
* on the host, through the gated `fetch` capability (andbox#39). http(s)
|
|
478
|
+
* only; `credentials` is the host's choice. Other network globals stay
|
|
479
|
+
* locked in worker modes; the platform `import()` operator is not affected.
|
|
480
|
+
* Unset (default): worker modes have no `fetch`. Conflicts with a
|
|
481
|
+
* capability named `fetch`.
|
|
482
|
+
*/
|
|
483
|
+
network?: SandboxNetworkOptions;
|
|
415
484
|
}
|
|
416
485
|
|
|
417
486
|
/** Options for the built-in Node worker_threads mode (`nodeWorker`). */
|
package/src/network-policy.mjs
CHANGED
|
@@ -46,3 +46,152 @@ 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
|
+
/**
|
|
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.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ fetch?: Function, allowedHosts?: string[], credentials?: RequestCredentials }} network
|
|
120
|
+
* @returns {(this: { signal?: AbortSignal }, url: unknown, init?: unknown) => Promise<object>}
|
|
121
|
+
*/
|
|
122
|
+
export function createFetchCapability(network) {
|
|
123
|
+
if (network === null || typeof network !== 'object' || Array.isArray(network)) {
|
|
124
|
+
throw new TypeError('network must be an object: { fetch?, allowedHosts?, credentials? }');
|
|
125
|
+
}
|
|
126
|
+
for (const key of Object.keys(network)) {
|
|
127
|
+
if (!NETWORK_KEYS.has(key)) {
|
|
128
|
+
throw new TypeError(`network.${key} is not a known option (expected fetch, allowedHosts, credentials)`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const { fetch: hostFetch, allowedHosts, credentials = 'omit' } = network;
|
|
132
|
+
if (hostFetch !== undefined && typeof hostFetch !== 'function') {
|
|
133
|
+
throw new TypeError('network.fetch must be a function (url, init) => Response');
|
|
134
|
+
}
|
|
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
|
+
}
|
|
139
|
+
if (allowedHosts.length === 0) {
|
|
140
|
+
throw new TypeError('network.allowedHosts must list at least one host; to give the sandbox no network, omit `network`');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (hostFetch === undefined && allowedHosts === undefined) {
|
|
144
|
+
throw new TypeError('network needs `fetch` (a host function) and/or `allowedHosts`');
|
|
145
|
+
}
|
|
146
|
+
if (!CREDENTIALS.includes(credentials)) {
|
|
147
|
+
throw new TypeError(`network.credentials must be one of ${CREDENTIALS.map((c) => `'${c}'`).join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const send = allowedHosts ? createNetworkFetch(allowedHosts, hostFetch) : hostFetch;
|
|
151
|
+
|
|
152
|
+
return async function fetchCapability(url, init) {
|
|
153
|
+
if (typeof url !== 'string') throw new TypeError('fetch: the URL must be a string');
|
|
154
|
+
let target;
|
|
155
|
+
try {
|
|
156
|
+
target = new URL(url);
|
|
157
|
+
} catch {
|
|
158
|
+
throw new TypeError(`fetch: invalid URL: ${url}`);
|
|
159
|
+
}
|
|
160
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
161
|
+
throw new TypeError(`fetch: only http(s) URLs are allowed (got ${target.protocol})`);
|
|
162
|
+
}
|
|
163
|
+
const req = init === undefined || init === null ? {} : init;
|
|
164
|
+
if (typeof req !== 'object') throw new TypeError('fetch: init must be an object');
|
|
165
|
+
|
|
166
|
+
const method = req.method ?? 'GET';
|
|
167
|
+
if (typeof method !== 'string' || !METHOD_TOKEN.test(method)) throw new TypeError('fetch: invalid method');
|
|
168
|
+
const pairs = req.headers ?? [];
|
|
169
|
+
if (!Array.isArray(pairs) || !pairs.every((p) => Array.isArray(p) && p.length === 2 && p.every((v) => typeof v === 'string'))) {
|
|
170
|
+
throw new TypeError('fetch: headers must be an array of [name, value] string pairs');
|
|
171
|
+
}
|
|
172
|
+
let body;
|
|
173
|
+
if (req.body !== undefined && req.bodyBase64 !== undefined) throw new TypeError('fetch: body and bodyBase64 are exclusive');
|
|
174
|
+
if (req.body !== undefined) {
|
|
175
|
+
if (typeof req.body !== 'string') throw new TypeError('fetch: body must be a string (binary goes in bodyBase64)');
|
|
176
|
+
body = req.body;
|
|
177
|
+
} else if (req.bodyBase64 !== undefined) {
|
|
178
|
+
if (typeof req.bodyBase64 !== 'string') throw new TypeError('fetch: bodyBase64 must be a string');
|
|
179
|
+
body = decodeBase64(req.bodyBase64);
|
|
180
|
+
}
|
|
181
|
+
if (req.redirect !== undefined && !REDIRECTS.includes(req.redirect)) throw new TypeError('fetch: invalid redirect mode');
|
|
182
|
+
|
|
183
|
+
const hostInit = {
|
|
184
|
+
method,
|
|
185
|
+
headers: new Headers(pairs),
|
|
186
|
+
...(body !== undefined ? { body } : {}),
|
|
187
|
+
...(req.redirect !== undefined ? { redirect: req.redirect } : {}),
|
|
188
|
+
// Always the host's choice; nothing from the sandbox can change it.
|
|
189
|
+
credentials,
|
|
190
|
+
...(this?.signal ? { signal: this.signal } : {}),
|
|
191
|
+
};
|
|
192
|
+
// Called without a `this`, so the platform's own fetch can be passed as
|
|
193
|
+
// network.fetch (it throws "Illegal invocation" on any other receiver).
|
|
194
|
+
const res = await send(target.href, hostInit);
|
|
195
|
+
return toWireResponse(res, target.href);
|
|
196
|
+
};
|
|
197
|
+
}
|
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 } 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 {{ 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)
|
|
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,13 @@ 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
|
+
}
|
|
349
360
|
if (mode === 'inline') return createInlineSandbox(options);
|
|
350
361
|
if (mode === 'data-uri') return createDataUriSandbox(options);
|
|
351
362
|
if (mode === 'service-worker') return createServiceWorkerSandbox(options);
|
|
@@ -437,6 +448,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
437
448
|
nodeWorker,
|
|
438
449
|
unref = false,
|
|
439
450
|
allowedImportHosts,
|
|
451
|
+
network,
|
|
440
452
|
} = options;
|
|
441
453
|
|
|
442
454
|
// undefined = unset: remote imports allowed. An array (even empty) restricts.
|
|
@@ -489,8 +501,22 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
489
501
|
wasmConfig = await resolveWasmConfig(options, baseURL, usingNode);
|
|
490
502
|
}
|
|
491
503
|
|
|
504
|
+
// `network` (andbox#39): the sandbox's global fetch calls the host's `fetch`
|
|
505
|
+
// capability, which goes through the same gate and `policy` as the others.
|
|
506
|
+
let gatedCapabilities = capabilities;
|
|
507
|
+
if (network !== undefined) {
|
|
508
|
+
if (Object.prototype.hasOwnProperty.call(capabilities, 'fetch')) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
"capabilities.fetch and the network option both define the 'fetch' capability; " +
|
|
511
|
+
'pass your function as network.fetch instead.'
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
gatedCapabilities = { ...capabilities, fetch: createFetchCapability(network) };
|
|
515
|
+
}
|
|
516
|
+
const networkFetch = network !== undefined;
|
|
517
|
+
|
|
492
518
|
// Gate capabilities with rate limits
|
|
493
|
-
const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(
|
|
519
|
+
const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(gatedCapabilities, policy);
|
|
494
520
|
|
|
495
521
|
// Console handler — mutable so evaluate() can swap per-call
|
|
496
522
|
let activeConsoleHandler = onConsole || null;
|
|
@@ -524,7 +550,9 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
524
550
|
|
|
525
551
|
function createWorker() {
|
|
526
552
|
workerAbort = new AbortController();
|
|
527
|
-
const source = isWasm
|
|
553
|
+
const source = isWasm
|
|
554
|
+
? makeWasmWorkerSource()
|
|
555
|
+
: isIframe ? makeIframeRuntimeSource({ networkFetch }) : makeWorkerSource({ networkFetch });
|
|
528
556
|
if (workerFactory) {
|
|
529
557
|
worker = workerFactory(source);
|
|
530
558
|
attachWorkerHandlers();
|
package/src/worker-source.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|