@johnhenry/andbox 0.1.1 → 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 +136 -20
- package/package.json +5 -2
- package/src/iframe-host.mjs +295 -0
- package/src/index.d.ts +134 -2
- package/src/network-policy.mjs +149 -0
- package/src/sandbox.mjs +76 -8
- package/src/worker-source.mjs +153 -8
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ Zero dependencies. Uses only Web Workers and standard browser APIs.
|
|
|
21
21
|
- [Sandbox Modes](#sandbox-modes)
|
|
22
22
|
- [Node](#node)
|
|
23
23
|
- [`mode: 'wasm'`](#mode-wasm)
|
|
24
|
+
- [`mode: 'iframe'`](#mode-iframe)
|
|
25
|
+
- [Mediated network: `network`](#mediated-network-network)
|
|
24
26
|
- [API](#api)
|
|
25
27
|
- [Execution model](#execution-model)
|
|
26
28
|
- [Security model](#security-model)
|
|
@@ -88,11 +90,12 @@ await sandbox.dispose();
|
|
|
88
90
|
|
|
89
91
|
## Sandbox Modes
|
|
90
92
|
|
|
91
|
-
andbox supports
|
|
93
|
+
andbox supports seven execution modes (any other `mode` throws an error listing these):
|
|
92
94
|
|
|
93
95
|
- **`worker`** (default) -- Runs in a dedicated Worker with an RPC bridge, import maps, virtual modules, and hard-kill timeout semantics. See [Security model](#security-model) for what this does and doesn't protect against.
|
|
94
96
|
- **`node-worker`** -- The `worker` mode on `node:worker_threads`. Selected automatically under Node when there is no global `Worker`; see [Node](#node).
|
|
95
97
|
- **`wasm`** -- Optional. Runs the code in QuickJS-ng compiled to WebAssembly *inside* the Worker (or worker thread), with `host.call` as the only authority and real memory, stack, fuel and deadline limits. The only mode that withholds the Worker's own globals (`fetch`, `WebSocket`, `importScripts`, ...). See [`mode: 'wasm'`](#mode-wasm).
|
|
98
|
+
- **`iframe`** -- Browser only. Runs the code in a sandboxed `<iframe sandbox="allow-scripts" srcdoc>`: an opaque origin with its own realm, `window` and `document`, so the code can render real DOM (charts, canvas animations, HTML) that you mount in your page. Same API as `worker`. The first mode with a browser-enforced origin boundary; see [`mode: 'iframe'`](#mode-iframe) for what that does and does not cover.
|
|
96
99
|
- **`inline`** -- Same-thread execution via AsyncFunction. Lighter weight, no Worker overhead, no isolation at all -- code runs with full access to the calling context. Only for code you already trust.
|
|
97
100
|
- **`data-uri`** -- Dynamic `import()` via Blob URL (a `data:` URL under Node). Module-level separation without a Worker. Supports globals injection.
|
|
98
101
|
- **`service-worker`** -- Not code execution at all: registers a Service Worker that serves an in-memory `path → content` map with real HTTP-shaped fetch/navigation semantics. For hosting a small virtual multi-file site (HTML/CSS/JS, arbitrary paths), not for running JS in isolation. See [andbox#14](https://github.com/johnhenry/andbox/issues/14) and [Security model](#security-model) -- this mode does **not** provide isolation by merely existing.
|
|
@@ -242,17 +245,104 @@ Or give them through the sandbox import map, so a page that already has one need
|
|
|
242
245
|
- A guest can catch the engine's out-of-memory error and keep running inside the cap; it cannot exceed the cap.
|
|
243
246
|
- `Date`, `Math.random` and `performance` exist in the guest (QuickJS provides them from the host clock); see the threat model.
|
|
244
247
|
|
|
248
|
+
## `mode: 'iframe'`
|
|
249
|
+
|
|
250
|
+
`mode: 'iframe'` (0.1.2) runs evaluated code in a sandboxed `<iframe>` instead of a Worker, for code that needs a real DOM: a notebook pane that draws a chart, plays a canvas animation, or renders HTML. It is the cross-origin-iframe option [andbox#10](https://github.com/johnhenry/andbox/issues/10) named next to `mode: 'wasm'`. The frame is created from `srcdoc` with `sandbox="allow-scripts"` and **no** `allow-same-origin`, so the browser gives it an opaque origin: it cannot read your page, your cookies or your storage.
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
import { createSandbox } from '@johnhenry/andbox';
|
|
254
|
+
|
|
255
|
+
const pane = await createSandbox({
|
|
256
|
+
mode: 'iframe',
|
|
257
|
+
container: document.querySelector('#pane'), // where the frame goes (default: offscreen in <body>)
|
|
258
|
+
html: '<style>body { margin: 0 }</style>', // initial body markup
|
|
259
|
+
capabilities: { data: () => [3, 7, 4, 9] },
|
|
260
|
+
onConsole: (level, ...args) => console.log(`[pane:${level}]`, ...args),
|
|
261
|
+
onFrame: (iframe) => { iframe.className = 'pane-frame'; }, // every new frame, before it is attached
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await pane.evaluate(`
|
|
265
|
+
const canvas = document.createElement('canvas'); // the frame's own document
|
|
266
|
+
document.body.append(canvas);
|
|
267
|
+
const values = await host.call('data');
|
|
268
|
+
// ... draw, animate, play(Scene, { canvas }) ...
|
|
269
|
+
return values.length;
|
|
270
|
+
`);
|
|
271
|
+
|
|
272
|
+
pane.iframe.style.height = '300px'; // the live element; size it like any other
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Same contract as worker mode.** `evaluate(code, opts)` wraps the code in an async IIFE and resolves with what it `return`s, by structured clone (`Map`, `Date`, typed arrays survive; a DOM node rejects with `DataCloneError`). `host.call(name, ...args)` goes through the same capability gate and `policy`, and capabilities get the same `this.signal`. `sandboxImport()` resolves virtual modules (`defineModule()`), the `importMap`, and remote URLs under the same `allowedImportHosts` rules. `console.*` is forwarded to `onConsole` (sandbox-level or per call). `timeoutMs`/`defaultTimeoutMs` and an `AbortSignal` hard-kill the frame (it is removed and a fresh one created) and reject with the same `TimeoutError` / `AbortError` as worker mode. `stats()`, `dispose()` and `isDisposed()` are unchanged.
|
|
276
|
+
|
|
277
|
+
**Differences from worker mode.**
|
|
278
|
+
|
|
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.
|
|
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()`.
|
|
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.
|
|
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.
|
|
283
|
+
- **`csp`** is injected as `<meta http-equiv="Content-Security-Policy">` after andbox's bootstrap script. `evaluate()` compiles code with `new Function`, so a policy that restricts scripts must allow `'unsafe-eval'`, plus `blob:` for `defineModule()` modules and the hosts you import from. Example: `"default-src 'none'; script-src 'unsafe-eval' blob: https://esm.sh; img-src data:"` leaves the frame no network except module imports from esm.sh.
|
|
284
|
+
- **`iframeSandbox: ['allow-forms', 'allow-popups', ...]`** adds sandbox tokens. `'allow-same-origin'` is refused: combined with `allow-scripts` it puts the frame in your origin, where it can reach your page and storage and delete its own `sandbox` attribute. `dangerouslyAllowSameOrigin: true` permits it for code you trust completely.
|
|
285
|
+
- **Browser only.** Without a DOM (Node, a Worker) `createSandbox({ mode: 'iframe' })` rejects. `workerFactory`, `nodeWorker` and the wasm limits do not apply; `untrusted: true` still means `mode: 'wasm'`.
|
|
286
|
+
- **Startup is bounded** by `defaultTimeoutMs`: a frame that never completes its handshake (for example a `container` that is not in a document) rejects `createSandbox()` and is removed.
|
|
287
|
+
|
|
288
|
+
**Synchronous infinite loops depend on the browser's process model.** An `await`-based hang (a promise that never settles, a long `setInterval`) is always killed on time. A synchronous `while (true) {}` can only be killed when the browser runs the frame on another thread:
|
|
289
|
+
|
|
290
|
+
- **Desktop Chrome** (site isolation on, the default; checked with Chrome 154) runs a sandboxed frame in its own renderer process. The timeout fires on time, the frame is removed, and the next call gets a new frame in milliseconds, **provided no other sandboxed frame from your site shares that process**. Chrome groups them by site, so while another andbox iframe (or any other opaque-origin frame from your site) is alive, the looping process cannot be shut down: the replacement frame lands in it and its startup times out, and those other frames stop responding too.
|
|
291
|
+
- **WebKit (Safari's engine) and Chromium without full site isolation** (measured: Playwright's WebKit, and Playwright's Chromium without `--site-per-process`) run the frame on the host page's main thread. A synchronous infinite loop freezes your page, the timeout cannot fire, and the browser's own "page unresponsive" handling is the only way out. Chrome on Android, which does not isolate every site, is expected to behave the same; Firefox was not measured for this release.
|
|
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.
|
|
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
|
+
|
|
245
335
|
## API
|
|
246
336
|
|
|
247
337
|
### `createSandbox(options?)`
|
|
248
338
|
|
|
249
|
-
Creates a new sandboxed runtime. Returns a promise (Worker
|
|
339
|
+
Creates a new sandboxed runtime. Returns a promise (Worker, wasm and iframe modes) or object (inline/data-uri mode).
|
|
250
340
|
|
|
251
341
|
**Options:**
|
|
252
342
|
|
|
253
343
|
| Option | Type | Default | Description |
|
|
254
344
|
|--------|------|---------|-------------|
|
|
255
|
-
| `mode` | `'worker' \| 'node-worker' \| 'wasm' \| 'inline' \| 'data-uri'` | `'worker'` | Execution mode |
|
|
345
|
+
| `mode` | `'worker' \| 'node-worker' \| 'wasm' \| 'iframe' \| 'inline' \| 'data-uri' \| 'service-worker'` | `'worker'` | Execution mode |
|
|
256
346
|
| `importMap` | `{ imports?, scopes? }` | `{}` | Import map for package resolution (Worker mode) |
|
|
257
347
|
| `capabilities` | `Record<string, Function>` | `{}` | Host functions callable via `host.call()` (Worker mode) |
|
|
258
348
|
| `defaultTimeoutMs` | `number` | `30000` | Default timeout for `evaluate()` |
|
|
@@ -264,8 +354,15 @@ Creates a new sandboxed runtime. Returns a promise (Worker mode) or object (inli
|
|
|
264
354
|
| `globals` | `Record<string, any>` | `{}` | Global variables (inline/data-uri modes) |
|
|
265
355
|
| `engineURL`, `wasmURL` | `string` | -- | `mode: 'wasm'`: same-origin URLs of the bundled engine module and the `.wasm` (optional under Node) |
|
|
266
356
|
| `fuel`, `memoryBytes`, `stackBytes`, `deadlineMs` | `number` | see [Limits](#limits) | `mode: 'wasm'` limits (also accepted per `evaluate()` call) |
|
|
357
|
+
| `container` | `Element` | offscreen in `document.body` | `mode: 'iframe'`: element the frame is appended to. A restarted frame takes its predecessor's place instead. |
|
|
358
|
+
| `html` | `string` | `''` | `mode: 'iframe'`: initial `<body>` markup of every new frame |
|
|
359
|
+
| `csp` | `string` | -- | `mode: 'iframe'`: Content-Security-Policy for the frame (`<meta http-equiv>`); must allow `'unsafe-eval'` if it restricts scripts |
|
|
360
|
+
| `iframeSandbox` | `string[]` | `[]` | `mode: 'iframe'`: extra sandbox tokens (`allow-scripts` is always set); `'allow-same-origin'` throws unless `dangerouslyAllowSameOrigin` |
|
|
361
|
+
| `dangerouslyAllowSameOrigin` | `boolean` | `false` | `mode: 'iframe'`: permit `'allow-same-origin'`, which removes the origin boundary |
|
|
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). |
|
|
267
364
|
|
|
268
|
-
**Returns (Worker mode):** `Promise<{ evaluate, defineModule, dispose, stats, isDisposed }
|
|
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)).
|
|
269
366
|
|
|
270
367
|
### `sandbox.evaluate(code, opts?)`
|
|
271
368
|
|
|
@@ -289,7 +386,7 @@ Defines a virtual module that sandbox code can import via `sandboxImport(name)`.
|
|
|
289
386
|
|
|
290
387
|
### `sandbox.dispose()`
|
|
291
388
|
|
|
292
|
-
Terminates the Worker and rejects all pending evaluations.
|
|
389
|
+
Terminates the Worker (removes the frame in `mode: 'iframe'`) and rejects all pending evaluations.
|
|
293
390
|
|
|
294
391
|
### `sandbox.stats()`
|
|
295
392
|
|
|
@@ -342,7 +439,7 @@ registry.dispose(); // revokes blob URLs / drop
|
|
|
342
439
|
|
|
343
440
|
### `createNetworkFetch(allowedHosts?, fetchFn?)`
|
|
344
441
|
|
|
345
|
-
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`.
|
|
346
443
|
|
|
347
444
|
```js
|
|
348
445
|
import { createNetworkFetch } from '@johnhenry/andbox';
|
|
@@ -360,9 +457,9 @@ Creates an async iterable stream for console output capture.
|
|
|
360
457
|
|
|
361
458
|
Promise and error utilities used internally, also available for consumers.
|
|
362
459
|
|
|
363
|
-
### `makeWorkerSource()`
|
|
460
|
+
### `makeWorkerSource(options?)`
|
|
364
461
|
|
|
365
|
-
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).
|
|
366
463
|
|
|
367
464
|
### `createSandbox({ mode: 'service-worker', ... })`
|
|
368
465
|
|
|
@@ -426,6 +523,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
426
523
|
**Pick the mode by how much you trust the code:**
|
|
427
524
|
|
|
428
525
|
- **`worker` and `node-worker` are for trusted or semi-trusted code** (your own scripts, plugins from known authors, LLM output you review). They organise and throttle what the code does; they do not contain a determined attacker. Still reachable from code in these modes: the platform `import()` operator (fetches and runs remote code, an exfiltration channel; `allowedImportHosts` only governs `sandboxImport()`), timing and `SharedArrayBuffer`/`Atomics` side channels, the Worker's shared realm and heap, any global a future platform adds that is not on the deny-list, and under Node `process`, `require` and the rest of the Node API.
|
|
526
|
+
- **`iframe` is for code that needs the DOM**, trusted or semi-trusted, that you want kept away from your page. The browser enforces an origin boundary: the frame runs in an opaque origin and a separate realm, so it cannot read your document, cookies, storage or JavaScript objects, and it reaches you only through `host.call()` and the values it returns. It does not limit what the code does with its own window: network, CPU, memory, and (with the matching `iframeSandbox` tokens) popups and forms are its own. See "still yours" below and [`mode: 'iframe'`](#mode-iframe).
|
|
429
527
|
- **`wasm` is the mode for untrusted code**, and `createSandbox({ untrusted: true })` selects it (and throws or rejects instead of falling back if it is unavailable, or if combined with another `mode`). The code runs in QuickJS compiled to WebAssembly with no ambient authority: no `fetch`, `import()` of URLs, timers, `process` or `require` exist in that engine, and its only way out is `host.call()` through `capabilities`, `policy` and the gate. It has real limits (`fuel`, `memoryBytes`, `stackBytes`, `deadlineMs`) and a hard `terminate()` backstop. It does **not** guarantee: that your own capabilities are safe (whatever you grant is reachable, so keep them narrow), protection from engine or WebAssembly-runtime bugs (still shared process memory; for hostile multi-tenant workloads add OS-level isolation), that an in-flight capability is cancelled when the cooperative deadline fires ([andbox#35](https://github.com/johnhenry/andbox/issues/35)), or Node-level hardening (`nodeWorker.permissions` is not supported in this mode). See [`mode: 'wasm'`](#mode-wasm) and [andbox#10](https://github.com/johnhenry/andbox/issues/10).
|
|
430
528
|
|
|
431
529
|
**What andbox guarantees:**
|
|
@@ -437,12 +535,21 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
437
535
|
- **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor; the host resolves names through a `Map` and rejects anything that was not explicitly granted. First fixed in 0.0.1, hardened in 0.0.9; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
|
|
438
536
|
- **`createNetworkFetch()`'s allowlist is redirect-safe.** Requests are made with `redirect: 'manual'` and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. Previously fixed; see [andbox#6](https://github.com/johnhenry/andbox/issues/6).
|
|
439
537
|
- **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
|
|
538
|
+
- **(`mode: 'iframe'`) An opaque origin and a separate realm, enforced by the browser.** The frame is `sandbox="allow-scripts"` without `allow-same-origin` (refused unless `dangerouslyAllowSameOrigin: true`). Its code gets `SecurityError` for `parent.document`, `top.location`, `parent.localStorage` and its own `localStorage`, has no access to your cookies, and shares no objects with your page: everything crossing the boundary is structured-cloned over a `MessagePort`. The port is handed over only after a handshake bound to that frame's own `contentWindow` and a per-frame random token (an `event.origin` of `'null'` alone proves nothing, since every opaque frame has it). Asserted by `test/browser/iframe-mode.spec.mjs`, which runs in Chromium, Firefox and WebKit.
|
|
440
539
|
|
|
441
540
|
**What is still yours:**
|
|
442
541
|
|
|
443
|
-
- **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).
|
|
444
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).
|
|
445
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).
|
|
546
|
+
- **`mode: 'iframe'`: the origin boundary is the whole guarantee.** Still yours:
|
|
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.
|
|
548
|
+
- **CPU and memory.** An `await`-based hang is killed on time; a synchronous loop is killable only where the browser runs the frame out of process, and in Chrome only while no other frame from your site shares that process. WebKit/Safari (and Chromium without full site isolation) run the frame on your page's thread, where a busy loop freezes your page. No memory cap. See [`mode: 'iframe'`](#mode-iframe).
|
|
549
|
+
- **Same process in some browsers.** Where the frame is not site-isolated (WebKit/Safari, Chromium without full site isolation such as Android Chrome, possibly Firefox) it shares a process and address space with your page: the origin boundary still holds for JavaScript, but a browser memory-safety bug or a Spectre-style read is not stopped by a process boundary. Timing side channels (`performance.now()`, `SharedArrayBuffer` where cross-origin isolation enables it) are available to the frame either way.
|
|
550
|
+
- **What you enable.** Every `iframeSandbox` token is a capability: `allow-popups` lets it open windows, `allow-forms` submit forms, `allow-top-navigation` navigate your page, `allow-modals` show dialogs. `dangerouslyAllowSameOrigin` removes the boundary entirely.
|
|
551
|
+
- **The UI it draws.** You chose to show the frame; it controls those pixels and can draw a convincing fake login form inside them. Keep frames visibly framed as untrusted content.
|
|
552
|
+
- **What you grant and what you accept.** Capabilities are as reachable as in any other mode, and results are values from untrusted code.
|
|
446
553
|
- **`mode: 'service-worker'` does not provide isolation by merely existing.** It's a hosting mechanism -- a real Service Worker, same-origin by default, serving your `files` map with real fetch/navigation interception. Content served through it can see and touch its own origin exactly like any other same-origin page can; nothing about registering a Service Worker sandboxes what runs inside the pages it serves. If you're hosting content you don't fully trust, point this mode at a genuinely separate origin from day one -- the same recommendation the `fetch`/`WebSocket`/`Worker` item above makes for `worker` mode (a cross-origin iframe with a strict CSP), not something bolted on after the fact. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
|
|
447
554
|
- **The Service Worker does not control the very first navigation into its scope.** A page/iframe navigation into `scope` that happens *before* the registration has finished activating is a normal, unintercepted network request -- Service Workers never retroactively intercept a request that already went out. `createSandbox({ mode: 'service-worker' })`'s returned promise only resolves once the registration is active (its generated script also calls `clients.claim()` on activate, which helps *already-open* clients but not fresh navigations); the documented, load-bearing contract is: don't navigate anything into `scope` until that promise resolves. Do that and every request is intercepted from the first byte, because the registration already matches `scope` before the navigation request is made. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
|
|
448
555
|
|
|
@@ -452,17 +559,17 @@ If you need to run untrusted/adversarial code, use `createSandbox({ untrusted: t
|
|
|
452
559
|
|
|
453
560
|
What each mode is built to stop, and what it is not. "Hostile" means code actively trying to escape or abuse the host.
|
|
454
561
|
|
|
455
|
-
| | `worker` / `node-worker` | `wasm` |
|
|
456
|
-
|
|
457
|
-
| **Runs in** | The Worker's own JS engine (`new Function`) | QuickJS-ng compiled to WebAssembly, inside the Worker / worker thread |
|
|
458
|
-
| **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
|
|
459
|
-
| **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`. |
|
|
460
|
-
| **`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. |
|
|
461
|
-
| **Prototype-chain names via `host.call`** | Closed by the capability gate (`Object.create(null)`). | Same gate, plus the guest never sees host objects. |
|
|
462
|
-
| **Infinite loops** | `terminate()` after `timeoutMs`, then a new Worker. | Deterministic `fuel` and a wall-clock `deadlineMs` stop it without a respawn; `terminate()` remains the backstop. |
|
|
463
|
-
| **Memory exhaustion** | Browser: nothing but the tab limit. Node: opt-in `nodeWorker.maxMemoryMb`. | Guest heap cap plus a hard cap on the engine's linear memory. |
|
|
464
|
-
| **Deep recursion** | Engine stack limit of the host JS engine. | `stackBytes`; overflow is a catchable `RangeError`. |
|
|
465
|
-
| **Capability abuse** | `gateCapabilities()` rate and size limits (cooperative callers). | Same. |
|
|
562
|
+
| | `worker` / `node-worker` | `wasm` | `iframe` |
|
|
563
|
+
|---|---|---|---|
|
|
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) |
|
|
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`). |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
569
|
+
| **Infinite loops** | `terminate()` after `timeoutMs`, then a new Worker. | Deterministic `fuel` and a wall-clock `deadlineMs` stop it without a respawn; `terminate()` remains the backstop. | Async hangs: frame removed after `timeoutMs`, then a new frame. Synchronous loops: only where the frame is out of process (desktop Chrome, with no other same-site sandboxed frame alive); elsewhere they freeze the page. |
|
|
570
|
+
| **Memory exhaustion** | Browser: nothing but the tab limit. Node: opt-in `nodeWorker.maxMemoryMb`. | Guest heap cap plus a hard cap on the engine's linear memory. | Nothing but the browser's per-process/tab limit. |
|
|
571
|
+
| **Deep recursion** | Engine stack limit of the host JS engine. | `stackBytes`; overflow is a catchable `RangeError`. | Engine stack limit. |
|
|
572
|
+
| **Capability abuse** | `gateCapabilities()` rate and size limits (cooperative callers). | Same. | Same. |
|
|
466
573
|
|
|
467
574
|
**What `wasm` mode does not defend against**
|
|
468
575
|
|
|
@@ -489,6 +596,15 @@ code-based tool execution.
|
|
|
489
596
|
is the source of truth for what that capability gate does and does not
|
|
490
597
|
guarantee -- the middleware's Security model section points back here
|
|
491
598
|
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.
|
|
492
608
|
|
|
493
609
|
## License
|
|
494
610
|
|
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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "node --test test/*.test.mjs",
|
|
25
|
+
"test:browser": "playwright test",
|
|
25
26
|
"example:01": "node examples/01-untrusted-code-runs-isolated.mjs",
|
|
26
27
|
"example:02": "node examples/02-capability-limits-cut-off-abuse.mjs",
|
|
27
28
|
"example:03": "node examples/03-network-allowlist-blocks-unapproved-hosts.mjs",
|
|
@@ -30,7 +31,8 @@
|
|
|
30
31
|
"examples": "npm run example:01 && npm run example:02 && npm run example:03 && npm run example:04 && npm run example:05 && npm run example:07",
|
|
31
32
|
"example:07": "node examples/07-wasm-mode-contains-hostile-code.mjs",
|
|
32
33
|
"example:08:build": "node examples/08-wasm-browser/build.mjs",
|
|
33
|
-
"example:08:headless": "node examples/08-wasm-browser/build.mjs && node examples/08-wasm-browser/run-headless.mjs"
|
|
34
|
+
"example:08:headless": "node examples/08-wasm-browser/build.mjs && node examples/08-wasm-browser/run-headless.mjs",
|
|
35
|
+
"example:09:headless": "node examples/09-iframe-browser/run-headless.mjs"
|
|
34
36
|
},
|
|
35
37
|
"keywords": [
|
|
36
38
|
"sandbox",
|
|
@@ -53,6 +55,7 @@
|
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@jitl/quickjs-ng-wasmfile-release-sync": "0.32.0",
|
|
58
|
+
"@playwright/test": "^1.63.0",
|
|
56
59
|
"esbuild": "^0.28.2",
|
|
57
60
|
"quickjs-emscripten-core": "0.32.0",
|
|
58
61
|
"vite": "^8.3.3",
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mode: 'iframe'` -- the host side.
|
|
3
|
+
*
|
|
4
|
+
* Each "worker" is a `<iframe sandbox="allow-scripts" srcdoc="...">`: an
|
|
5
|
+
* opaque origin with its own realm, `window` and `document`. The srcdoc holds
|
|
6
|
+
* a small bootstrap plus the same runtime script Worker mode uses (without
|
|
7
|
+
* the Worker-global lockdown). The host talks to it over a MessageChannel
|
|
8
|
+
* whose second port is transferred to the frame after a token-checked
|
|
9
|
+
* handshake; after that nothing travels over `window.postMessage`.
|
|
10
|
+
*
|
|
11
|
+
* `createIframeFactory()` returns a `workerFactory`-compatible function whose
|
|
12
|
+
* result is Worker-shaped (`postMessage`, `onmessage`, `onerror`,
|
|
13
|
+
* `add/removeEventListener('message')`, `terminate()`, `dead`), so the rest of
|
|
14
|
+
* the sandbox (RPC, timeouts, restarts, capability abort) is shared with
|
|
15
|
+
* Worker mode unchanged.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { makeRuntimeSource } from './worker-source.mjs';
|
|
19
|
+
|
|
20
|
+
/** The sandbox token andbox always sets; the frame cannot run code without it. */
|
|
21
|
+
const BASE_TOKEN = 'allow-scripts';
|
|
22
|
+
|
|
23
|
+
/** Inline style for the default, evaluation-only placement (no `container`). */
|
|
24
|
+
const OFFSCREEN_STYLE =
|
|
25
|
+
'position:absolute;left:-10000px;top:0;width:1px;height:1px;border:0;opacity:0;pointer-events:none;';
|
|
26
|
+
|
|
27
|
+
/** Attributes andbox owns on the frame; never copied onto a replacement frame. */
|
|
28
|
+
const OWNED_ATTRIBUTES = new Set(['srcdoc', 'src', 'sandbox']);
|
|
29
|
+
|
|
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 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Make text safe to place inside an inline `<script>` element. `</script` would
|
|
41
|
+
* end the element early and `<!--` can switch the tokenizer into the
|
|
42
|
+
* "script data escaped" state; both only occur inside string literals here.
|
|
43
|
+
*/
|
|
44
|
+
function escapeInlineScript(text) {
|
|
45
|
+
return text.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function escapeAttribute(text) {
|
|
49
|
+
return text.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the frame's `srcdoc`.
|
|
54
|
+
*
|
|
55
|
+
* Order matters: the bootstrap runs first, then the optional CSP `<meta>`
|
|
56
|
+
* (a meta policy applies to what comes after it, so the bootstrap is never
|
|
57
|
+
* blocked by it, while `eval`/`new Function`, imports, fetches and any
|
|
58
|
+
* scripts in `html` are), then the caller's `html` in `<body>`.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ source: string, token: string, csp?: string, html?: string }} parts
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function makeIframeDocument({ source, token, csp, html = '' }) {
|
|
64
|
+
const bootstrap = `
|
|
65
|
+
(function () {
|
|
66
|
+
'use strict';
|
|
67
|
+
var TOKEN = ${JSON.stringify(token)};
|
|
68
|
+
var parentWindow = window.parent;
|
|
69
|
+
var started = false;
|
|
70
|
+
function onInit(e) {
|
|
71
|
+
if (started || e.source !== parentWindow) return;
|
|
72
|
+
var d = e.data;
|
|
73
|
+
if (!d || d.type !== 'andbox:init' || d.token !== TOKEN || !e.ports || !e.ports[0]) return;
|
|
74
|
+
started = true;
|
|
75
|
+
window.removeEventListener('message', onInit);
|
|
76
|
+
var port = e.ports[0];
|
|
77
|
+
var send = port.postMessage.bind(port);
|
|
78
|
+
// No pagehide/unload listener here on purpose: in Chromium one blocks fast
|
|
79
|
+
// shutdown of the frame's process, so a frame stuck in a busy loop would
|
|
80
|
+
// keep its (shared) process hung for seconds after the host removed it.
|
|
81
|
+
// The host detects navigation from the element's load events instead.
|
|
82
|
+
var portScope = {
|
|
83
|
+
postMessage: send,
|
|
84
|
+
close: function () { port.close(); },
|
|
85
|
+
set onmessage(fn) { port.onmessage = fn; },
|
|
86
|
+
};
|
|
87
|
+
(function (self) {
|
|
88
|
+
${escapeInlineScript(source)}
|
|
89
|
+
})(portScope);
|
|
90
|
+
}
|
|
91
|
+
window.addEventListener('message', onInit);
|
|
92
|
+
function hello() { parentWindow.postMessage({ type: 'andbox:hello', token: TOKEN }, '*'); }
|
|
93
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hello, { once: true });
|
|
94
|
+
else hello();
|
|
95
|
+
})();
|
|
96
|
+
`;
|
|
97
|
+
const cspMeta = csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(csp)}">` : '';
|
|
98
|
+
return `<!doctype html><html><head><meta charset="utf-8"><script>${bootstrap}</script>${cspMeta}</head><body>${html}</body></html>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate the iframe-only options. Throws on anything that would silently
|
|
103
|
+
* weaken the boundary.
|
|
104
|
+
*
|
|
105
|
+
* @returns {{ tokens: string[], allowSameOrigin: boolean, csp: string | undefined, html: string, container: Element | null, onFrame: ((f: HTMLIFrameElement) => void) | null }}
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeIframeOptions(options = {}) {
|
|
108
|
+
const {
|
|
109
|
+
iframeSandbox = [],
|
|
110
|
+
dangerouslyAllowSameOrigin = false,
|
|
111
|
+
csp,
|
|
112
|
+
html = '',
|
|
113
|
+
container = null,
|
|
114
|
+
onFrame = null,
|
|
115
|
+
} = options;
|
|
116
|
+
|
|
117
|
+
if (!Array.isArray(iframeSandbox) || !iframeSandbox.every((t) => typeof t === 'string')) {
|
|
118
|
+
throw new TypeError("iframeSandbox must be an array of sandbox token strings, e.g. ['allow-forms']");
|
|
119
|
+
}
|
|
120
|
+
const tokens = [BASE_TOKEN];
|
|
121
|
+
for (const raw of iframeSandbox) {
|
|
122
|
+
const t = raw.trim().toLowerCase();
|
|
123
|
+
if (!/^allow-[a-z-]+$/.test(t)) {
|
|
124
|
+
throw new TypeError(`iframeSandbox: '${raw}' is not a sandbox token (expected 'allow-...')`);
|
|
125
|
+
}
|
|
126
|
+
if (!tokens.includes(t)) tokens.push(t);
|
|
127
|
+
}
|
|
128
|
+
const allowSameOrigin = tokens.includes('allow-same-origin');
|
|
129
|
+
if (allowSameOrigin && dangerouslyAllowSameOrigin !== true) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"iframeSandbox: 'allow-same-origin' together with 'allow-scripts' (always set in mode: 'iframe') lets the " +
|
|
132
|
+
'framed code reach your page, its cookies and storage, and remove its own sandbox attribute -- there is no ' +
|
|
133
|
+
'boundary left. Pass dangerouslyAllowSameOrigin: true only if the code is fully trusted.'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (csp !== undefined && typeof csp !== 'string') throw new TypeError('csp must be a Content-Security-Policy string');
|
|
137
|
+
if (typeof html !== 'string') throw new TypeError('html must be a string of body markup');
|
|
138
|
+
if (onFrame !== null && typeof onFrame !== 'function') throw new TypeError('onFrame must be a function');
|
|
139
|
+
if (container !== null && (typeof container !== 'object' || typeof container.appendChild !== 'function')) {
|
|
140
|
+
throw new TypeError('container must be a DOM element');
|
|
141
|
+
}
|
|
142
|
+
return { tokens, allowSameOrigin, csp: csp || undefined, html, container, onFrame };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create the iframe "worker" factory for one sandbox.
|
|
147
|
+
*
|
|
148
|
+
* @param {ReturnType<typeof normalizeIframeOptions>} opts
|
|
149
|
+
* @param {number} startupTimeoutMs how long to wait for a new frame's handshake
|
|
150
|
+
* @returns {{ factory: (source: string) => object, current: () => HTMLIFrameElement | null }}
|
|
151
|
+
*/
|
|
152
|
+
export function createIframeFactory(opts, startupTimeoutMs) {
|
|
153
|
+
const win = globalThis;
|
|
154
|
+
const doc = globalThis.document;
|
|
155
|
+
if (!doc || typeof doc.createElement !== 'function' || typeof win.addEventListener !== 'function') {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"mode: 'iframe' needs a DOM (a browser page with `document`); it is not available under Node or inside a Worker."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
// Where the last frame was, so a restart puts its replacement in the same
|
|
161
|
+
// spot with the same attributes (class, style, width, ...).
|
|
162
|
+
let slot = null;
|
|
163
|
+
let current = null;
|
|
164
|
+
|
|
165
|
+
function factory(source) {
|
|
166
|
+
const token = crypto.randomUUID() + crypto.randomUUID();
|
|
167
|
+
const frame = doc.createElement('iframe');
|
|
168
|
+
if (slot) {
|
|
169
|
+
for (const [name, value] of slot.attributes) frame.setAttribute(name, value);
|
|
170
|
+
} else {
|
|
171
|
+
if (!opts.container) {
|
|
172
|
+
frame.setAttribute('style', OFFSCREEN_STYLE);
|
|
173
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
174
|
+
frame.setAttribute('tabindex', '-1');
|
|
175
|
+
}
|
|
176
|
+
frame.setAttribute('title', 'andbox sandbox');
|
|
177
|
+
}
|
|
178
|
+
frame.setAttribute('sandbox', opts.tokens.join(' '));
|
|
179
|
+
frame.srcdoc = makeIframeDocument({ source, token, csp: opts.csp, html: opts.html });
|
|
180
|
+
|
|
181
|
+
const channel = new MessageChannel();
|
|
182
|
+
const port = channel.port1;
|
|
183
|
+
const listeners = new Set();
|
|
184
|
+
const expectedOrigin = opts.allowSameOrigin ? win.location?.origin : 'null';
|
|
185
|
+
let terminated = false;
|
|
186
|
+
let startTimer = null;
|
|
187
|
+
|
|
188
|
+
const adapter = {
|
|
189
|
+
iframe: frame,
|
|
190
|
+
dead: false,
|
|
191
|
+
onmessage: null,
|
|
192
|
+
onerror: null,
|
|
193
|
+
// Messages posted before the handshake wait in the port's queue and
|
|
194
|
+
// travel with the transferred port, like messages to a starting Worker.
|
|
195
|
+
postMessage(message) {
|
|
196
|
+
if (!terminated) port.postMessage(message);
|
|
197
|
+
},
|
|
198
|
+
addEventListener(type, fn) {
|
|
199
|
+
if (type === 'message') listeners.add(fn);
|
|
200
|
+
},
|
|
201
|
+
removeEventListener(type, fn) {
|
|
202
|
+
if (type === 'message') listeners.delete(fn);
|
|
203
|
+
},
|
|
204
|
+
terminate() {
|
|
205
|
+
if (terminated) return;
|
|
206
|
+
terminated = true;
|
|
207
|
+
adapter.dead = true;
|
|
208
|
+
cleanupHandshake();
|
|
209
|
+
try { port.close(); } catch {}
|
|
210
|
+
slot = {
|
|
211
|
+
parent: frame.parentNode,
|
|
212
|
+
next: frame.nextSibling,
|
|
213
|
+
attributes: [...frame.attributes]
|
|
214
|
+
.filter((a) => !OWNED_ATTRIBUTES.has(a.name))
|
|
215
|
+
.map((a) => [a.name, a.value]),
|
|
216
|
+
};
|
|
217
|
+
frame.remove();
|
|
218
|
+
if (current === adapter) current = null;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function fail(message) {
|
|
223
|
+
if (terminated || adapter.dead) return;
|
|
224
|
+
adapter.dead = true;
|
|
225
|
+
cleanupHandshake();
|
|
226
|
+
adapter.onerror?.({ message });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
port.onmessage = (event) => {
|
|
230
|
+
if (terminated) return;
|
|
231
|
+
adapter.onmessage?.(event);
|
|
232
|
+
for (const fn of [...listeners]) fn(event);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Bind the handshake to this frame's own WindowProxy and its secret token.
|
|
236
|
+
// `event.origin` is checked too, but on its own it would prove nothing:
|
|
237
|
+
// every opaque-origin frame reports 'null'.
|
|
238
|
+
function onHello(event) {
|
|
239
|
+
if (terminated || event.source === null || event.source !== frame.contentWindow) return;
|
|
240
|
+
const data = event.data;
|
|
241
|
+
if (!data || data.type !== 'andbox:hello' || data.token !== token) return;
|
|
242
|
+
if (expectedOrigin && event.origin !== expectedOrigin) return;
|
|
243
|
+
cleanupHandshake();
|
|
244
|
+
frame.contentWindow.postMessage({ type: 'andbox:init', token }, '*', [channel.port2]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function cleanupHandshake() {
|
|
248
|
+
win.removeEventListener('message', onHello);
|
|
249
|
+
if (startTimer !== null) { clearTimeout(startTimer); startTimer = null; }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The element's first load event is the srcdoc document; any later one
|
|
253
|
+
// means a different document now lives in the frame (the code navigated
|
|
254
|
+
// or reloaded it, or the element was moved in the DOM), and the runtime
|
|
255
|
+
// that held our port is gone.
|
|
256
|
+
let loads = 0;
|
|
257
|
+
frame.addEventListener('load', () => {
|
|
258
|
+
if (++loads > 1) fail('Sandbox iframe unloaded (it navigated, was reloaded, or was moved in the DOM)');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
win.addEventListener('message', onHello);
|
|
262
|
+
if (startupTimeoutMs > 0) {
|
|
263
|
+
startTimer = setTimeout(() => {
|
|
264
|
+
startTimer = null;
|
|
265
|
+
fail(
|
|
266
|
+
`Sandbox iframe did not start within ${startupTimeoutMs}ms ` +
|
|
267
|
+
'(is its container attached to a document, and does csp/html leave its bootstrap intact?)'
|
|
268
|
+
);
|
|
269
|
+
}, startupTimeoutMs);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
opts.onFrame?.(frame);
|
|
274
|
+
if (!frame.isConnected) {
|
|
275
|
+
const prev = slot;
|
|
276
|
+
if (prev?.parent?.isConnected) {
|
|
277
|
+
prev.parent.insertBefore(frame, prev.next && prev.next.parentNode === prev.parent ? prev.next : null);
|
|
278
|
+
} else {
|
|
279
|
+
(opts.container || doc.body || doc.documentElement).appendChild(frame);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {
|
|
283
|
+
adapter.terminate();
|
|
284
|
+
throw e;
|
|
285
|
+
}
|
|
286
|
+
slot = null;
|
|
287
|
+
current = adapter;
|
|
288
|
+
return adapter;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
factory,
|
|
293
|
+
current: () => current?.iframe ?? null,
|
|
294
|
+
};
|
|
295
|
+
}
|
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
|
|
|
@@ -341,7 +400,8 @@ export interface SandboxOptions {
|
|
|
341
400
|
* `Worker`. `'node-worker'` forces the Node implementation.
|
|
342
401
|
*/
|
|
343
402
|
mode?: 'worker' | 'node-worker' | 'wasm';
|
|
344
|
-
// Any other value throws: supported modes are 'worker', 'node-worker', 'wasm', 'inline', 'data-uri', 'service-worker'.
|
|
403
|
+
// Any other value throws: supported modes are 'worker', 'node-worker', 'wasm', 'iframe', 'inline', 'data-uri', 'service-worker'.
|
|
404
|
+
// 'iframe' takes IframeSandboxOptions (below).
|
|
345
405
|
/**
|
|
346
406
|
* `mode: 'wasm'` only. URL of an ES module built from
|
|
347
407
|
* `@johnhenry/andbox/wasm-engine` (the QuickJS engine entry), served from
|
|
@@ -411,6 +471,16 @@ export interface SandboxOptions {
|
|
|
411
471
|
* wasm mode is unavailable.
|
|
412
472
|
*/
|
|
413
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;
|
|
414
484
|
}
|
|
415
485
|
|
|
416
486
|
/** Options for the built-in Node worker_threads mode (`nodeWorker`). */
|
|
@@ -523,6 +593,64 @@ export interface Sandbox {
|
|
|
523
593
|
isDisposed(): boolean;
|
|
524
594
|
}
|
|
525
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Options for `createSandbox({ mode: 'iframe' })`: a sandboxed
|
|
598
|
+
* `<iframe sandbox="allow-scripts" srcdoc>` with an opaque origin, its own
|
|
599
|
+
* realm, `window` and `document`. Browser only (rejects without a DOM).
|
|
600
|
+
* All the Worker-mode options apply except `workerFactory`, `nodeWorker`,
|
|
601
|
+
* `unref` and the `mode: 'wasm'` limits.
|
|
602
|
+
*/
|
|
603
|
+
export interface IframeSandboxOptions
|
|
604
|
+
extends Omit<SandboxOptions, 'mode' | 'workerFactory' | 'nodeWorker' | 'unref' | 'untrusted' | 'engineURL' | 'wasmURL' | 'fuel' | 'memoryBytes' | 'stackBytes' | 'deadlineMs'> {
|
|
605
|
+
/** Mode discriminant. */
|
|
606
|
+
mode: 'iframe';
|
|
607
|
+
/**
|
|
608
|
+
* Element each frame is appended to. Default: `document.body`, placed
|
|
609
|
+
* offscreen (1x1 px at -10000px, `aria-hidden`) for evaluation-only use.
|
|
610
|
+
* A restarted frame takes its predecessor's place and attributes instead.
|
|
611
|
+
*/
|
|
612
|
+
container?: Element;
|
|
613
|
+
/** Initial `<body>` markup of every new frame (also after a restart). Subject to `csp`. */
|
|
614
|
+
html?: string;
|
|
615
|
+
/**
|
|
616
|
+
* Content-Security-Policy injected as a `<meta http-equiv>` after andbox's
|
|
617
|
+
* bootstrap. evaluate() compiles code with `new Function`, so a policy that
|
|
618
|
+
* restricts scripts must allow `'unsafe-eval'` (and `blob:` for
|
|
619
|
+
* `defineModule()` modules, plus any hosts you `sandboxImport()` from).
|
|
620
|
+
*/
|
|
621
|
+
csp?: string;
|
|
622
|
+
/**
|
|
623
|
+
* Extra sandbox tokens, e.g. `['allow-forms', 'allow-popups']`.
|
|
624
|
+
* `allow-scripts` is always set. `'allow-same-origin'` is refused unless
|
|
625
|
+
* `dangerouslyAllowSameOrigin` is true.
|
|
626
|
+
*/
|
|
627
|
+
iframeSandbox?: string[];
|
|
628
|
+
/**
|
|
629
|
+
* Permit `'allow-same-origin'` in `iframeSandbox`. Together with
|
|
630
|
+
* `allow-scripts` that removes the boundary entirely: the frame runs in your
|
|
631
|
+
* origin and can reach your page, cookies and storage, and un-sandbox itself.
|
|
632
|
+
*/
|
|
633
|
+
dangerouslyAllowSameOrigin?: boolean;
|
|
634
|
+
/**
|
|
635
|
+
* Called synchronously with every new frame (the first and each one created
|
|
636
|
+
* by a timeout/abort/unload restart) before andbox attaches it. Style it,
|
|
637
|
+
* set attributes such as `allow`, or insert it yourself; if it is still
|
|
638
|
+
* detached when this returns, andbox attaches it.
|
|
639
|
+
*/
|
|
640
|
+
onFrame?: (iframe: HTMLIFrameElement) => void;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** A sandbox created with `mode: 'iframe'`. */
|
|
644
|
+
export interface IframeSandbox extends Sandbox {
|
|
645
|
+
/**
|
|
646
|
+
* The live frame element. It is replaced by a new element after a timeout,
|
|
647
|
+
* an abort, or the frame navigating/reloading (use `onFrame` to follow
|
|
648
|
+
* replacements), and is `null` after dispose(). Do not move it in the DOM:
|
|
649
|
+
* that reloads the document and loses the sandbox state.
|
|
650
|
+
*/
|
|
651
|
+
readonly iframe: HTMLIFrameElement | null;
|
|
652
|
+
}
|
|
653
|
+
|
|
526
654
|
/** Options for createSandbox({ mode: 'service-worker' }). */
|
|
527
655
|
export interface ServiceWorkerSandboxOptions {
|
|
528
656
|
/** Mode discriminant. */
|
|
@@ -582,6 +710,9 @@ export interface ServiceWorkerSandbox {
|
|
|
582
710
|
* - Console forwarding
|
|
583
711
|
* - Capability gating with rate limits
|
|
584
712
|
*
|
|
713
|
+
* `mode: 'iframe'` runs the code in a sandboxed, opaque-origin `<iframe>`
|
|
714
|
+
* with a real DOM, and adds `iframe` to the returned object.
|
|
715
|
+
*
|
|
585
716
|
* `mode: 'service-worker'` is a different shape entirely: it hosts a
|
|
586
717
|
* path → content map behind a real, same-origin, HTTP-shaped scope
|
|
587
718
|
* instead of evaluating code — see andbox#14 and README's Security model
|
|
@@ -590,6 +721,7 @@ export interface ServiceWorkerSandbox {
|
|
|
590
721
|
* @param options Sandbox configuration options.
|
|
591
722
|
* @returns A promise that resolves to the sandbox instance.
|
|
592
723
|
*/
|
|
724
|
+
export declare function createSandbox(options: IframeSandboxOptions): Promise<IframeSandbox>;
|
|
593
725
|
export declare function createSandbox(options?: SandboxOptions): Promise<Sandbox>;
|
|
594
726
|
export declare function createSandbox(
|
|
595
727
|
options: ServiceWorkerSandboxOptions,
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* andbox — Sandboxed JavaScript runtime.
|
|
3
3
|
*
|
|
4
|
-
* Creates an isolated Web Worker sandbox with:
|
|
4
|
+
* Creates an isolated Web Worker (or, with mode: 'iframe', sandboxed iframe) sandbox with:
|
|
5
5
|
* - RPC-based capability calls (host.call)
|
|
6
6
|
* - Import map resolution
|
|
7
7
|
* - Virtual module definitions
|
|
@@ -17,6 +17,8 @@ import { gateCapabilities } from './capability-gate.mjs';
|
|
|
17
17
|
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
|
+
import { normalizeIframeOptions, createIframeFactory, makeIframeRuntimeSource } from './iframe-host.mjs';
|
|
21
|
+
import { createFetchCapability } from './network-policy.mjs';
|
|
20
22
|
|
|
21
23
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
22
24
|
|
|
@@ -305,15 +307,24 @@ async function createServiceWorkerSandbox(options = {}) {
|
|
|
305
307
|
* @property {string} [baseURL] - Base URL for relative imports
|
|
306
308
|
* @property {import('./capability-gate.mjs').GatePolicy} [policy] - Rate limiting policy
|
|
307
309
|
* @property {(level: string, ...args: string[]) => void} [onConsole] - Console output handler
|
|
310
|
+
* @property {Element} [container] - mode: 'iframe': element the frame is appended to (default: offscreen in document.body)
|
|
311
|
+
* @property {string} [html] - mode: 'iframe': initial body markup
|
|
312
|
+
* @property {string} [csp] - mode: 'iframe': Content-Security-Policy for the frame
|
|
313
|
+
* @property {string[]} [iframeSandbox] - mode: 'iframe': extra sandbox tokens ('allow-same-origin' needs dangerouslyAllowSameOrigin)
|
|
314
|
+
* @property {boolean} [dangerouslyAllowSameOrigin] - mode: 'iframe': permit 'allow-same-origin' (removes the origin boundary)
|
|
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)
|
|
308
317
|
*/
|
|
309
318
|
|
|
310
|
-
const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'inline', 'data-uri', 'service-worker'];
|
|
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'];
|
|
311
322
|
|
|
312
323
|
/**
|
|
313
324
|
* Create a new sandboxed runtime.
|
|
314
325
|
*
|
|
315
326
|
* @param {SandboxOptions | ServiceWorkerSandboxOptions} [options]
|
|
316
|
-
* @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean }> | Promise<{ scriptURL: string, scope: string, define: Function, remove: Function, dispose: Function, isDisposed: () => boolean }>}
|
|
327
|
+
* @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean, iframe?: HTMLIFrameElement | null }> | Promise<{ scriptURL: string, scope: string, define: Function, remove: Function, dispose: Function, isDisposed: () => boolean }>}
|
|
317
328
|
*/
|
|
318
329
|
export function createSandbox(options = {}) {
|
|
319
330
|
if (options.untrusted === true) {
|
|
@@ -339,10 +350,17 @@ export function createSandbox(options = {}) {
|
|
|
339
350
|
`Supported modes: ${SUPPORTED_MODES.map((m) => `'${m}'`).join(', ')}.`
|
|
340
351
|
);
|
|
341
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
|
+
}
|
|
342
360
|
if (mode === 'inline') return createInlineSandbox(options);
|
|
343
361
|
if (mode === 'data-uri') return createDataUriSandbox(options);
|
|
344
362
|
if (mode === 'service-worker') return createServiceWorkerSandbox(options);
|
|
345
|
-
return createWorkerSandbox(options, mode
|
|
363
|
+
return createWorkerSandbox(options, mode);
|
|
346
364
|
}
|
|
347
365
|
|
|
348
366
|
/** Default limits for `mode: 'wasm'` (0 = unlimited / not enforced). */
|
|
@@ -408,7 +426,18 @@ async function resolveWasmConfig(options, baseURL, usingNode) {
|
|
|
408
426
|
};
|
|
409
427
|
}
|
|
410
428
|
|
|
411
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Worker-shaped sandboxes: `worker`, `node-worker`, `wasm` and `iframe`. They
|
|
431
|
+
* share one host implementation; `iframe` swaps the Worker for a sandboxed
|
|
432
|
+
* `<iframe>` adapter (src/iframe-host.mjs) behind the same interface.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} options
|
|
435
|
+
* @param {'worker' | 'node-worker' | 'wasm' | 'iframe'} [kind]
|
|
436
|
+
*/
|
|
437
|
+
async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
438
|
+
const forceNode = kind === 'node-worker';
|
|
439
|
+
const isWasm = kind === 'wasm';
|
|
440
|
+
const isIframe = kind === 'iframe';
|
|
412
441
|
const {
|
|
413
442
|
importMap = { imports: {}, scopes: {} },
|
|
414
443
|
capabilities = {},
|
|
@@ -419,6 +448,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
419
448
|
nodeWorker,
|
|
420
449
|
unref = false,
|
|
421
450
|
allowedImportHosts,
|
|
451
|
+
network,
|
|
422
452
|
} = options;
|
|
423
453
|
|
|
424
454
|
// undefined = unset: remote imports allowed. An array (even empty) restricts.
|
|
@@ -433,6 +463,18 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
433
463
|
// default selects it only when there is no global Worker under Node.
|
|
434
464
|
let workerFactory = options.workerFactory || null;
|
|
435
465
|
let usingNode = false;
|
|
466
|
+
// mode: 'iframe' -- a sandboxed, opaque-origin <iframe> instead of a Worker.
|
|
467
|
+
let iframeHost = null;
|
|
468
|
+
if (isIframe) {
|
|
469
|
+
if (options.workerFactory || nodeWorker) {
|
|
470
|
+
throw new Error("workerFactory and nodeWorker do not apply to mode: 'iframe'.");
|
|
471
|
+
}
|
|
472
|
+
iframeHost = createIframeFactory(
|
|
473
|
+
normalizeIframeOptions(options),
|
|
474
|
+
defaultTimeoutMs > 0 ? defaultTimeoutMs : DEFAULT_TIMEOUT_MS
|
|
475
|
+
);
|
|
476
|
+
workerFactory = iframeHost.factory;
|
|
477
|
+
}
|
|
436
478
|
if (!workerFactory && (forceNode || (typeof Worker === 'undefined' && isNodeRuntime()))) {
|
|
437
479
|
workerFactory = await createNodeWorkerFactory(nodeWorker);
|
|
438
480
|
usingNode = true;
|
|
@@ -459,8 +501,22 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
459
501
|
wasmConfig = await resolveWasmConfig(options, baseURL, usingNode);
|
|
460
502
|
}
|
|
461
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
|
+
|
|
462
518
|
// Gate capabilities with rate limits
|
|
463
|
-
const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(
|
|
519
|
+
const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(gatedCapabilities, policy);
|
|
464
520
|
|
|
465
521
|
// Console handler — mutable so evaluate() can swap per-call
|
|
466
522
|
let activeConsoleHandler = onConsole || null;
|
|
@@ -494,7 +550,9 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
494
550
|
|
|
495
551
|
function createWorker() {
|
|
496
552
|
workerAbort = new AbortController();
|
|
497
|
-
const source = isWasm
|
|
553
|
+
const source = isWasm
|
|
554
|
+
? makeWasmWorkerSource()
|
|
555
|
+
: isIframe ? makeIframeRuntimeSource({ networkFetch }) : makeWorkerSource({ networkFetch });
|
|
498
556
|
if (workerFactory) {
|
|
499
557
|
worker = workerFactory(source);
|
|
500
558
|
attachWorkerHandlers();
|
|
@@ -817,11 +875,21 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
817
875
|
endOp();
|
|
818
876
|
}
|
|
819
877
|
|
|
820
|
-
|
|
878
|
+
const sandbox = {
|
|
821
879
|
evaluate,
|
|
822
880
|
defineModule,
|
|
823
881
|
dispose,
|
|
824
882
|
stats,
|
|
825
883
|
isDisposed: () => disposed,
|
|
826
884
|
};
|
|
885
|
+
if (iframeHost) {
|
|
886
|
+
// The live frame. A timeout, abort or unload replaces it with a new
|
|
887
|
+
// element (same container position and attributes; `onFrame` is called
|
|
888
|
+
// for every new one), and it is null after dispose().
|
|
889
|
+
Object.defineProperty(sandbox, 'iframe', {
|
|
890
|
+
enumerable: true,
|
|
891
|
+
get: () => (disposed ? null : iframeHost.current()),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
return sandbox;
|
|
827
895
|
}
|
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,9 +134,43 @@
|
|
|
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() {
|
|
143
|
+
export function makeWorkerSource({ networkFetch = false } = {}) {
|
|
144
|
+
return makeRuntimeSource({ lockdown: true, networkFetch });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The shared runtime behind `makeWorkerSource()` (Worker) and `mode: 'iframe'`.
|
|
149
|
+
*
|
|
150
|
+
* The script talks to the host through a `self`-shaped object with
|
|
151
|
+
* `postMessage`, `close` and an `onmessage` setter: the real Worker global
|
|
152
|
+
* scope, or (iframe mode) a wrapper around a MessagePort the frame was handed.
|
|
153
|
+
*
|
|
154
|
+
* @param {{ lockdown?: boolean, networkFetch?: boolean }} [options]
|
|
155
|
+
* `lockdown` (default true) deletes and shadows the Worker's ambient
|
|
156
|
+
* network/worker globals (andbox#10). The iframe runtime turns it off: there
|
|
157
|
+
* the browser's opaque-origin boundary is the isolation, and evaluated code is
|
|
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.
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function makeRuntimeSource({ lockdown = true, networkFetch = false } = {}) {
|
|
166
|
+
const lockedGlobals = lockdown
|
|
167
|
+
? `[
|
|
168
|
+
${networkFetch ? '' : "'fetch', "}'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource',
|
|
169
|
+
'Worker', 'SharedWorker', 'importScripts', 'indexedDB', 'caches', 'BroadcastChannel',
|
|
170
|
+
'postMessage', 'self',
|
|
171
|
+
]`
|
|
172
|
+
: '[]';
|
|
173
|
+
const shadowed = lockdown ? "[...LOCKED_GLOBALS, 'window']" : '[]';
|
|
29
174
|
return `
|
|
30
175
|
'use strict';
|
|
31
176
|
|
|
@@ -48,20 +193,20 @@ try { delete globalThis.__andboxNodeVirtual; } catch {}
|
|
|
48
193
|
const scope = self;
|
|
49
194
|
const post = self.postMessage.bind(self);
|
|
50
195
|
const closeSelf = typeof self.close === 'function' ? self.close.bind(self) : () => {};
|
|
51
|
-
const LOCKED_GLOBALS =
|
|
52
|
-
'fetch', 'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource',
|
|
53
|
-
'Worker', 'SharedWorker', 'importScripts', 'indexedDB', 'caches', 'BroadcastChannel',
|
|
54
|
-
'postMessage', 'self',
|
|
55
|
-
];
|
|
196
|
+
const LOCKED_GLOBALS = ${lockedGlobals};
|
|
56
197
|
for (const k of LOCKED_GLOBALS) {
|
|
57
198
|
try { delete globalThis[k]; } catch {}
|
|
58
199
|
if (k in globalThis) {
|
|
59
200
|
try { Object.defineProperty(globalThis, k, { value: undefined, writable: false, configurable: false }); } catch {}
|
|
60
201
|
}
|
|
61
202
|
}
|
|
62
|
-
|
|
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
|
|
63
208
|
// where a global could not be deleted).
|
|
64
|
-
const SHADOWED =
|
|
209
|
+
const SHADOWED = ${shadowed};
|
|
65
210
|
|
|
66
211
|
// ── Remote import policy (andbox#7) ──
|
|
67
212
|
function assertImportAllowed(href) {
|