@johnhenry/andbox 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ 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)
24
25
  - [API](#api)
25
26
  - [Execution model](#execution-model)
26
27
  - [Security model](#security-model)
@@ -88,11 +89,12 @@ await sandbox.dispose();
88
89
 
89
90
  ## Sandbox Modes
90
91
 
91
- andbox supports six execution modes (any other `mode` throws an error listing these):
92
+ andbox supports seven execution modes (any other `mode` throws an error listing these):
92
93
 
93
94
  - **`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
95
  - **`node-worker`** -- The `worker` mode on `node:worker_threads`. Selected automatically under Node when there is no global `Worker`; see [Node](#node).
95
96
  - **`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).
97
+ - **`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
98
  - **`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
99
  - **`data-uri`** -- Dynamic `import()` via Blob URL (a `data:` URL under Node). Module-level separation without a Worker. Supports globals injection.
98
100
  - **`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 +244,64 @@ Or give them through the sandbox import map, so a page that already has one need
242
244
  - A guest can catch the engine's out-of-memory error and keep running inside the cap; it cannot exceed the cap.
243
245
  - `Date`, `Math.random` and `performance` exist in the guest (QuickJS provides them from the host clock); see the threat model.
244
246
 
247
+ ## `mode: 'iframe'`
248
+
249
+ `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.
250
+
251
+ ```js
252
+ import { createSandbox } from '@johnhenry/andbox';
253
+
254
+ const pane = await createSandbox({
255
+ mode: 'iframe',
256
+ container: document.querySelector('#pane'), // where the frame goes (default: offscreen in <body>)
257
+ html: '<style>body { margin: 0 }</style>', // initial body markup
258
+ capabilities: { data: () => [3, 7, 4, 9] },
259
+ onConsole: (level, ...args) => console.log(`[pane:${level}]`, ...args),
260
+ onFrame: (iframe) => { iframe.className = 'pane-frame'; }, // every new frame, before it is attached
261
+ });
262
+
263
+ await pane.evaluate(`
264
+ const canvas = document.createElement('canvas'); // the frame's own document
265
+ document.body.append(canvas);
266
+ const values = await host.call('data');
267
+ // ... draw, animate, play(Scene, { canvas }) ...
268
+ return values.length;
269
+ `);
270
+
271
+ pane.iframe.style.height = '300px'; // the live element; size it like any other
272
+ ```
273
+
274
+ **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.
275
+
276
+ **Differences from worker mode.**
277
+
278
+ - **The code has a full browser window.** `window`, `document`, `fetch`, timers and `requestAnimationFrame` are the frame's own; nothing is deleted or shadowed (the worker-mode lockdown does not apply, the origin boundary does). `document.body.append(...)` renders where you mounted the frame.
279
+ - **`sandbox.iframe` is the live element, and it changes.** A timeout, an abort, or the frame navigating/reloading itself replaces it with a new element in the same place, with the same attributes (`class`, `style`, `width`, ...); everything the old document held is gone. `onFrame(iframe)` is called for every new frame before it is attached, so set things up there if you need them on every frame, or insert the frame yourself there. `sandbox.iframe` is `null` after `dispose()`.
280
+ - **Do not move the element in the DOM.** Re-parenting an iframe reloads its document. andbox notices (the pending call rejects with `Sandbox iframe unloaded ...`) and the next call gets a fresh frame, but state is lost. Pass `container` (or place it in `onFrame`) instead.
281
+ - **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.
282
+ - **`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.
283
+ - **`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.
284
+ - **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'`.
285
+ - **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.
286
+
287
+ **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:
288
+
289
+ - **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.
290
+ - **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.
291
+
292
+ Use `mode: 'wasm'` (fuel and deadline inside the engine) or `worker` mode for code that may spin, and `iframe` mode for code that needs the DOM. `examples/09-iframe-browser/` is a working two-pane notebook demo; `test/browser/iframe-mode.spec.mjs` runs the contract in Chromium, Firefox and WebKit.
293
+
245
294
  ## API
246
295
 
247
296
  ### `createSandbox(options?)`
248
297
 
249
- Creates a new sandboxed runtime. Returns a promise (Worker mode) or object (inline/data-uri mode).
298
+ Creates a new sandboxed runtime. Returns a promise (Worker, wasm and iframe modes) or object (inline/data-uri mode).
250
299
 
251
300
  **Options:**
252
301
 
253
302
  | Option | Type | Default | Description |
254
303
  |--------|------|---------|-------------|
255
- | `mode` | `'worker' \| 'node-worker' \| 'wasm' \| 'inline' \| 'data-uri'` | `'worker'` | Execution mode |
304
+ | `mode` | `'worker' \| 'node-worker' \| 'wasm' \| 'iframe' \| 'inline' \| 'data-uri' \| 'service-worker'` | `'worker'` | Execution mode |
256
305
  | `importMap` | `{ imports?, scopes? }` | `{}` | Import map for package resolution (Worker mode) |
257
306
  | `capabilities` | `Record<string, Function>` | `{}` | Host functions callable via `host.call()` (Worker mode) |
258
307
  | `defaultTimeoutMs` | `number` | `30000` | Default timeout for `evaluate()` |
@@ -264,8 +313,14 @@ Creates a new sandboxed runtime. Returns a promise (Worker mode) or object (inli
264
313
  | `globals` | `Record<string, any>` | `{}` | Global variables (inline/data-uri modes) |
265
314
  | `engineURL`, `wasmURL` | `string` | -- | `mode: 'wasm'`: same-origin URLs of the bundled engine module and the `.wasm` (optional under Node) |
266
315
  | `fuel`, `memoryBytes`, `stackBytes`, `deadlineMs` | `number` | see [Limits](#limits) | `mode: 'wasm'` limits (also accepted per `evaluate()` call) |
316
+ | `container` | `Element` | offscreen in `document.body` | `mode: 'iframe'`: element the frame is appended to. A restarted frame takes its predecessor's place instead. |
317
+ | `html` | `string` | `''` | `mode: 'iframe'`: initial `<body>` markup of every new frame |
318
+ | `csp` | `string` | -- | `mode: 'iframe'`: Content-Security-Policy for the frame (`<meta http-equiv>`); must allow `'unsafe-eval'` if it restricts scripts |
319
+ | `iframeSandbox` | `string[]` | `[]` | `mode: 'iframe'`: extra sandbox tokens (`allow-scripts` is always set); `'allow-same-origin'` throws unless `dangerouslyAllowSameOrigin` |
320
+ | `dangerouslyAllowSameOrigin` | `boolean` | `false` | `mode: 'iframe'`: permit `'allow-same-origin'`, which removes the origin boundary |
321
+ | `onFrame` | `(iframe) => void` | -- | `mode: 'iframe'`: called with every new frame (first and after each restart) before it is attached |
267
322
 
268
- **Returns (Worker mode):** `Promise<{ evaluate, defineModule, dispose, stats, isDisposed }>`
323
+ **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
324
 
270
325
  ### `sandbox.evaluate(code, opts?)`
271
326
 
@@ -289,7 +344,7 @@ Defines a virtual module that sandbox code can import via `sandboxImport(name)`.
289
344
 
290
345
  ### `sandbox.dispose()`
291
346
 
292
- Terminates the Worker and rejects all pending evaluations.
347
+ Terminates the Worker (removes the frame in `mode: 'iframe'`) and rejects all pending evaluations.
293
348
 
294
349
  ### `sandbox.stats()`
295
350
 
@@ -426,6 +481,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
426
481
  **Pick the mode by how much you trust the code:**
427
482
 
428
483
  - **`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.
484
+ - **`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
485
  - **`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
486
 
431
487
  **What andbox guarantees:**
@@ -437,12 +493,20 @@ andbox is **not** a boundary against code that is actively trying to escape it.
437
493
  - **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
494
  - **`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
495
  - **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
496
+ - **(`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
497
 
441
498
  **What is still yours:**
442
499
 
443
500
  - **Worker-global APIs: partly removed, not contained.** Since 0.1.0 the worker prelude deletes `fetch`, `WebSocket`, `WebSocketStream`, `WebTransport`, `EventSource`, `XMLHttpRequest`, `Worker`, `SharedWorker`, `importScripts`, `indexedDB`, `caches`, `BroadcastChannel`, `postMessage` and `self` from the global scope before any evaluated code runs (after the runtime has captured what it needs), shadows those names for evaluated code, and gives evaluated code a throwaway `this`. A script no longer gets them by name, through `globalThis`, indirect `eval` or `Function`. **This is hardening, not a boundary, and a Worker is not a security boundary.** Still reachable: the platform `import()` operator (syntax, it cannot be deleted or shadowed: it can fetch and execute remote code and is an exfiltration channel; `allowedImportHosts` only governs `sandboxImport()`), timing and `SharedArrayBuffer`/`Atomics` side channels, anything the engine or platform adds later that is not on the list above (a deny-list can only ever be incomplete), and under Node `process`, `require` and the rest of the Node API (use `nodeWorker.permissions`, and see [Node](#node)). Everything shares the Worker's realm and heap, so any prototype or intrinsic the code mutates is shared with the runtime. A different isolation primitive is required for hostile code: [`mode: 'wasm'`](#mode-wasm) (QuickJS in WebAssembly, no ambient authority) or a cross-origin iframe with a strict CSP. See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
444
501
  - **`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).
445
502
  - **A timeout cannot undo in-flight host-side effects; it can ask them to stop.** When the Worker is terminated (timeout, an aborted `evaluate()`, `dispose()`, a crash) every capability call still in flight sees `this.signal` abort, and its late result is dropped rather than delivered. Cancellation is cooperative: a capability that ignores `this.signal` still runs to completion on the host. Write effectful capabilities as `function`s (not arrows) and pass the signal on (`fetch(url, { signal: this.signal })`), and keep them idempotent. In `mode: 'wasm'`, a cooperative `deadlineMs` ends the evaluation without terminating the Worker, so the signal does not abort in that case. See [andbox#8](https://github.com/johnhenry/andbox/issues/8).
503
+ - **`mode: 'iframe'`: the origin boundary is the whole guarantee.** Still yours:
504
+ - **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.
505
+ - **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).
506
+ - **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.
507
+ - **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.
508
+ - **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.
509
+ - **What you grant and what you accept.** Capabilities are as reachable as in any other mode, and results are values from untrusted code.
446
510
  - **`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
511
  - **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
512
 
@@ -452,17 +516,17 @@ If you need to run untrusted/adversarial code, use `createSandbox({ untrusted: t
452
516
 
453
517
  What each mode is built to stop, and what it is not. "Hostile" means code actively trying to escape or abuse the host.
454
518
 
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`. 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. |
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. |
519
+ | | `worker` / `node-worker` | `wasm` | `iframe` |
520
+ |---|---|---|---|
521
+ | **Runs in** | The Worker's own JS engine (`new Function`) | QuickJS-ng compiled to WebAssembly, inside the Worker / worker thread | The browser's JS engine, in a sandboxed opaque-origin frame (`new Function` in the frame's realm) |
522
+ | **Reaching `fetch`, `WebSocket`, `importScripts`, `indexedDB`, `postMessage`** | Removed from the global scope by the prelude (0.1.0), so not reachable by name or via `globalThis`/`eval`/`Function`. Deny-list hardening only: `import()`, timing channels and (Node) `process`/`require` remain. | Not possible. The engine has no such globals, and `constructor`/`eval`/`Function` chains only reach the guest realm. | Available, as the frame's own (`null`-origin) APIs; restrict the network with `csp`. Your page, cookies and storage are not reachable (`SecurityError`). |
523
+ | **Forging protocol messages to the host** | `postMessage`/`self` are removed; ids are random UUIDs and each `result` must echo a per-evaluate nonce. | Not possible. The guest has no `postMessage` or `self`. | Only over its own `MessagePort`, with the same random ids and nonce; the host accepts nothing else from the frame. |
524
+ | **`sandboxImport` of arbitrary URLs / Node builtins** | Remote `http(s)` URLs allowed unless `allowedImportHosts` is provided (then only listed hosts; `[]` denies all); the raw `import()` operator is still unrestricted (browser), and Node builtins are blocked only with `nodeWorker.permissions` (Node). | Refused: only virtual modules resolve; no URL is fetched. | As worker mode (`allowedImportHosts` governs `sandboxImport()` only); requests are cross-origin from `null`. `csp` can restrict `import()` too. |
525
+ | **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. |
526
+ | **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. |
527
+ | **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. |
528
+ | **Deep recursion** | Engine stack limit of the host JS engine. | `stackBytes`; overflow is a catchable `RangeError`. | Engine stack limit. |
529
+ | **Capability abuse** | `gateCapabilities()` rate and size limits (cooperative callers). | Same. | Same. |
466
530
 
467
531
  **What `wasm` mode does not defend against**
468
532
 
@@ -489,6 +553,15 @@ code-based tool execution.
489
553
  is the source of truth for what that capability gate does and does not
490
554
  guarantee -- the middleware's Security model section points back here
491
555
  rather than repeating it.
556
+ - **[`@johnhenry/prism`](https://github.com/johnhenry/prism)** -- a live
557
+ HTTP request inspector/proxy whose custom script-route feature runs
558
+ user-provided route handlers via `createSandbox({ mode: 'inline' })`.
559
+ Deliberately uses `inline` mode, not the default `worker` mode: the
560
+ handler needs a live `Request` object (with its body stream) directly in
561
+ scope, which can't cross a Worker's structured-clone boundary, and the
562
+ feature's predecessor (a package called `vimble`) never provided real
563
+ isolation either -- `inline`'s explicit "no isolation, code you already
564
+ trust" framing is the honest match, not a downgrade from what came before.
492
565
 
493
566
  ## License
494
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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,291 @@
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
+ /** The runtime script the frame runs: Worker mode's, minus the global lockdown. */
31
+ export function makeIframeRuntimeSource() {
32
+ return makeRuntimeSource({ lockdown: false });
33
+ }
34
+
35
+ /**
36
+ * Make text safe to place inside an inline `<script>` element. `</script` would
37
+ * end the element early and `<!--` can switch the tokenizer into the
38
+ * "script data escaped" state; both only occur inside string literals here.
39
+ */
40
+ function escapeInlineScript(text) {
41
+ return text.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--');
42
+ }
43
+
44
+ function escapeAttribute(text) {
45
+ return text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
46
+ }
47
+
48
+ /**
49
+ * Build the frame's `srcdoc`.
50
+ *
51
+ * Order matters: the bootstrap runs first, then the optional CSP `<meta>`
52
+ * (a meta policy applies to what comes after it, so the bootstrap is never
53
+ * blocked by it, while `eval`/`new Function`, imports, fetches and any
54
+ * scripts in `html` are), then the caller's `html` in `<body>`.
55
+ *
56
+ * @param {{ source: string, token: string, csp?: string, html?: string }} parts
57
+ * @returns {string}
58
+ */
59
+ export function makeIframeDocument({ source, token, csp, html = '' }) {
60
+ const bootstrap = `
61
+ (function () {
62
+ 'use strict';
63
+ var TOKEN = ${JSON.stringify(token)};
64
+ var parentWindow = window.parent;
65
+ var started = false;
66
+ function onInit(e) {
67
+ if (started || e.source !== parentWindow) return;
68
+ var d = e.data;
69
+ if (!d || d.type !== 'andbox:init' || d.token !== TOKEN || !e.ports || !e.ports[0]) return;
70
+ started = true;
71
+ window.removeEventListener('message', onInit);
72
+ var port = e.ports[0];
73
+ var send = port.postMessage.bind(port);
74
+ // No pagehide/unload listener here on purpose: in Chromium one blocks fast
75
+ // shutdown of the frame's process, so a frame stuck in a busy loop would
76
+ // keep its (shared) process hung for seconds after the host removed it.
77
+ // The host detects navigation from the element's load events instead.
78
+ var portScope = {
79
+ postMessage: send,
80
+ close: function () { port.close(); },
81
+ set onmessage(fn) { port.onmessage = fn; },
82
+ };
83
+ (function (self) {
84
+ ${escapeInlineScript(source)}
85
+ })(portScope);
86
+ }
87
+ window.addEventListener('message', onInit);
88
+ function hello() { parentWindow.postMessage({ type: 'andbox:hello', token: TOKEN }, '*'); }
89
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hello, { once: true });
90
+ else hello();
91
+ })();
92
+ `;
93
+ const cspMeta = csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(csp)}">` : '';
94
+ return `<!doctype html><html><head><meta charset="utf-8"><script>${bootstrap}</script>${cspMeta}</head><body>${html}</body></html>`;
95
+ }
96
+
97
+ /**
98
+ * Validate the iframe-only options. Throws on anything that would silently
99
+ * weaken the boundary.
100
+ *
101
+ * @returns {{ tokens: string[], allowSameOrigin: boolean, csp: string | undefined, html: string, container: Element | null, onFrame: ((f: HTMLIFrameElement) => void) | null }}
102
+ */
103
+ export function normalizeIframeOptions(options = {}) {
104
+ const {
105
+ iframeSandbox = [],
106
+ dangerouslyAllowSameOrigin = false,
107
+ csp,
108
+ html = '',
109
+ container = null,
110
+ onFrame = null,
111
+ } = options;
112
+
113
+ if (!Array.isArray(iframeSandbox) || !iframeSandbox.every((t) => typeof t === 'string')) {
114
+ throw new TypeError("iframeSandbox must be an array of sandbox token strings, e.g. ['allow-forms']");
115
+ }
116
+ const tokens = [BASE_TOKEN];
117
+ for (const raw of iframeSandbox) {
118
+ const t = raw.trim().toLowerCase();
119
+ if (!/^allow-[a-z-]+$/.test(t)) {
120
+ throw new TypeError(`iframeSandbox: '${raw}' is not a sandbox token (expected 'allow-...')`);
121
+ }
122
+ if (!tokens.includes(t)) tokens.push(t);
123
+ }
124
+ const allowSameOrigin = tokens.includes('allow-same-origin');
125
+ if (allowSameOrigin && dangerouslyAllowSameOrigin !== true) {
126
+ throw new Error(
127
+ "iframeSandbox: 'allow-same-origin' together with 'allow-scripts' (always set in mode: 'iframe') lets the " +
128
+ 'framed code reach your page, its cookies and storage, and remove its own sandbox attribute -- there is no ' +
129
+ 'boundary left. Pass dangerouslyAllowSameOrigin: true only if the code is fully trusted.'
130
+ );
131
+ }
132
+ if (csp !== undefined && typeof csp !== 'string') throw new TypeError('csp must be a Content-Security-Policy string');
133
+ if (typeof html !== 'string') throw new TypeError('html must be a string of body markup');
134
+ if (onFrame !== null && typeof onFrame !== 'function') throw new TypeError('onFrame must be a function');
135
+ if (container !== null && (typeof container !== 'object' || typeof container.appendChild !== 'function')) {
136
+ throw new TypeError('container must be a DOM element');
137
+ }
138
+ return { tokens, allowSameOrigin, csp: csp || undefined, html, container, onFrame };
139
+ }
140
+
141
+ /**
142
+ * Create the iframe "worker" factory for one sandbox.
143
+ *
144
+ * @param {ReturnType<typeof normalizeIframeOptions>} opts
145
+ * @param {number} startupTimeoutMs how long to wait for a new frame's handshake
146
+ * @returns {{ factory: (source: string) => object, current: () => HTMLIFrameElement | null }}
147
+ */
148
+ export function createIframeFactory(opts, startupTimeoutMs) {
149
+ const win = globalThis;
150
+ const doc = globalThis.document;
151
+ if (!doc || typeof doc.createElement !== 'function' || typeof win.addEventListener !== 'function') {
152
+ throw new Error(
153
+ "mode: 'iframe' needs a DOM (a browser page with `document`); it is not available under Node or inside a Worker."
154
+ );
155
+ }
156
+ // Where the last frame was, so a restart puts its replacement in the same
157
+ // spot with the same attributes (class, style, width, ...).
158
+ let slot = null;
159
+ let current = null;
160
+
161
+ function factory(source) {
162
+ const token = crypto.randomUUID() + crypto.randomUUID();
163
+ const frame = doc.createElement('iframe');
164
+ if (slot) {
165
+ for (const [name, value] of slot.attributes) frame.setAttribute(name, value);
166
+ } else {
167
+ if (!opts.container) {
168
+ frame.setAttribute('style', OFFSCREEN_STYLE);
169
+ frame.setAttribute('aria-hidden', 'true');
170
+ frame.setAttribute('tabindex', '-1');
171
+ }
172
+ frame.setAttribute('title', 'andbox sandbox');
173
+ }
174
+ frame.setAttribute('sandbox', opts.tokens.join(' '));
175
+ frame.srcdoc = makeIframeDocument({ source, token, csp: opts.csp, html: opts.html });
176
+
177
+ const channel = new MessageChannel();
178
+ const port = channel.port1;
179
+ const listeners = new Set();
180
+ const expectedOrigin = opts.allowSameOrigin ? win.location?.origin : 'null';
181
+ let terminated = false;
182
+ let startTimer = null;
183
+
184
+ const adapter = {
185
+ iframe: frame,
186
+ dead: false,
187
+ onmessage: null,
188
+ onerror: null,
189
+ // Messages posted before the handshake wait in the port's queue and
190
+ // travel with the transferred port, like messages to a starting Worker.
191
+ postMessage(message) {
192
+ if (!terminated) port.postMessage(message);
193
+ },
194
+ addEventListener(type, fn) {
195
+ if (type === 'message') listeners.add(fn);
196
+ },
197
+ removeEventListener(type, fn) {
198
+ if (type === 'message') listeners.delete(fn);
199
+ },
200
+ terminate() {
201
+ if (terminated) return;
202
+ terminated = true;
203
+ adapter.dead = true;
204
+ cleanupHandshake();
205
+ try { port.close(); } catch {}
206
+ slot = {
207
+ parent: frame.parentNode,
208
+ next: frame.nextSibling,
209
+ attributes: [...frame.attributes]
210
+ .filter((a) => !OWNED_ATTRIBUTES.has(a.name))
211
+ .map((a) => [a.name, a.value]),
212
+ };
213
+ frame.remove();
214
+ if (current === adapter) current = null;
215
+ },
216
+ };
217
+
218
+ function fail(message) {
219
+ if (terminated || adapter.dead) return;
220
+ adapter.dead = true;
221
+ cleanupHandshake();
222
+ adapter.onerror?.({ message });
223
+ }
224
+
225
+ port.onmessage = (event) => {
226
+ if (terminated) return;
227
+ adapter.onmessage?.(event);
228
+ for (const fn of [...listeners]) fn(event);
229
+ };
230
+
231
+ // Bind the handshake to this frame's own WindowProxy and its secret token.
232
+ // `event.origin` is checked too, but on its own it would prove nothing:
233
+ // every opaque-origin frame reports 'null'.
234
+ function onHello(event) {
235
+ if (terminated || event.source === null || event.source !== frame.contentWindow) return;
236
+ const data = event.data;
237
+ if (!data || data.type !== 'andbox:hello' || data.token !== token) return;
238
+ if (expectedOrigin && event.origin !== expectedOrigin) return;
239
+ cleanupHandshake();
240
+ frame.contentWindow.postMessage({ type: 'andbox:init', token }, '*', [channel.port2]);
241
+ }
242
+
243
+ function cleanupHandshake() {
244
+ win.removeEventListener('message', onHello);
245
+ if (startTimer !== null) { clearTimeout(startTimer); startTimer = null; }
246
+ }
247
+
248
+ // The element's first load event is the srcdoc document; any later one
249
+ // means a different document now lives in the frame (the code navigated
250
+ // or reloaded it, or the element was moved in the DOM), and the runtime
251
+ // that held our port is gone.
252
+ let loads = 0;
253
+ frame.addEventListener('load', () => {
254
+ if (++loads > 1) fail('Sandbox iframe unloaded (it navigated, was reloaded, or was moved in the DOM)');
255
+ });
256
+
257
+ win.addEventListener('message', onHello);
258
+ if (startupTimeoutMs > 0) {
259
+ startTimer = setTimeout(() => {
260
+ startTimer = null;
261
+ fail(
262
+ `Sandbox iframe did not start within ${startupTimeoutMs}ms ` +
263
+ '(is its container attached to a document, and does csp/html leave its bootstrap intact?)'
264
+ );
265
+ }, startupTimeoutMs);
266
+ }
267
+
268
+ try {
269
+ opts.onFrame?.(frame);
270
+ if (!frame.isConnected) {
271
+ const prev = slot;
272
+ if (prev?.parent?.isConnected) {
273
+ prev.parent.insertBefore(frame, prev.next && prev.next.parentNode === prev.parent ? prev.next : null);
274
+ } else {
275
+ (opts.container || doc.body || doc.documentElement).appendChild(frame);
276
+ }
277
+ }
278
+ } catch (e) {
279
+ adapter.terminate();
280
+ throw e;
281
+ }
282
+ slot = null;
283
+ current = adapter;
284
+ return adapter;
285
+ }
286
+
287
+ return {
288
+ factory,
289
+ current: () => current?.iframe ?? null,
290
+ };
291
+ }
package/src/index.d.ts CHANGED
@@ -341,7 +341,8 @@ export interface SandboxOptions {
341
341
  * `Worker`. `'node-worker'` forces the Node implementation.
342
342
  */
343
343
  mode?: 'worker' | 'node-worker' | 'wasm';
344
- // Any other value throws: supported modes are 'worker', 'node-worker', 'wasm', 'inline', 'data-uri', 'service-worker'.
344
+ // Any other value throws: supported modes are 'worker', 'node-worker', 'wasm', 'iframe', 'inline', 'data-uri', 'service-worker'.
345
+ // 'iframe' takes IframeSandboxOptions (below).
345
346
  /**
346
347
  * `mode: 'wasm'` only. URL of an ES module built from
347
348
  * `@johnhenry/andbox/wasm-engine` (the QuickJS engine entry), served from
@@ -523,6 +524,64 @@ export interface Sandbox {
523
524
  isDisposed(): boolean;
524
525
  }
525
526
 
527
+ /**
528
+ * Options for `createSandbox({ mode: 'iframe' })`: a sandboxed
529
+ * `<iframe sandbox="allow-scripts" srcdoc>` with an opaque origin, its own
530
+ * realm, `window` and `document`. Browser only (rejects without a DOM).
531
+ * All the Worker-mode options apply except `workerFactory`, `nodeWorker`,
532
+ * `unref` and the `mode: 'wasm'` limits.
533
+ */
534
+ export interface IframeSandboxOptions
535
+ extends Omit<SandboxOptions, 'mode' | 'workerFactory' | 'nodeWorker' | 'unref' | 'untrusted' | 'engineURL' | 'wasmURL' | 'fuel' | 'memoryBytes' | 'stackBytes' | 'deadlineMs'> {
536
+ /** Mode discriminant. */
537
+ mode: 'iframe';
538
+ /**
539
+ * Element each frame is appended to. Default: `document.body`, placed
540
+ * offscreen (1x1 px at -10000px, `aria-hidden`) for evaluation-only use.
541
+ * A restarted frame takes its predecessor's place and attributes instead.
542
+ */
543
+ container?: Element;
544
+ /** Initial `<body>` markup of every new frame (also after a restart). Subject to `csp`. */
545
+ html?: string;
546
+ /**
547
+ * Content-Security-Policy injected as a `<meta http-equiv>` after andbox's
548
+ * bootstrap. evaluate() compiles code with `new Function`, so a policy that
549
+ * restricts scripts must allow `'unsafe-eval'` (and `blob:` for
550
+ * `defineModule()` modules, plus any hosts you `sandboxImport()` from).
551
+ */
552
+ csp?: string;
553
+ /**
554
+ * Extra sandbox tokens, e.g. `['allow-forms', 'allow-popups']`.
555
+ * `allow-scripts` is always set. `'allow-same-origin'` is refused unless
556
+ * `dangerouslyAllowSameOrigin` is true.
557
+ */
558
+ iframeSandbox?: string[];
559
+ /**
560
+ * Permit `'allow-same-origin'` in `iframeSandbox`. Together with
561
+ * `allow-scripts` that removes the boundary entirely: the frame runs in your
562
+ * origin and can reach your page, cookies and storage, and un-sandbox itself.
563
+ */
564
+ dangerouslyAllowSameOrigin?: boolean;
565
+ /**
566
+ * Called synchronously with every new frame (the first and each one created
567
+ * by a timeout/abort/unload restart) before andbox attaches it. Style it,
568
+ * set attributes such as `allow`, or insert it yourself; if it is still
569
+ * detached when this returns, andbox attaches it.
570
+ */
571
+ onFrame?: (iframe: HTMLIFrameElement) => void;
572
+ }
573
+
574
+ /** A sandbox created with `mode: 'iframe'`. */
575
+ export interface IframeSandbox extends Sandbox {
576
+ /**
577
+ * The live frame element. It is replaced by a new element after a timeout,
578
+ * an abort, or the frame navigating/reloading (use `onFrame` to follow
579
+ * replacements), and is `null` after dispose(). Do not move it in the DOM:
580
+ * that reloads the document and loses the sandbox state.
581
+ */
582
+ readonly iframe: HTMLIFrameElement | null;
583
+ }
584
+
526
585
  /** Options for createSandbox({ mode: 'service-worker' }). */
527
586
  export interface ServiceWorkerSandboxOptions {
528
587
  /** Mode discriminant. */
@@ -582,6 +641,9 @@ export interface ServiceWorkerSandbox {
582
641
  * - Console forwarding
583
642
  * - Capability gating with rate limits
584
643
  *
644
+ * `mode: 'iframe'` runs the code in a sandboxed, opaque-origin `<iframe>`
645
+ * with a real DOM, and adds `iframe` to the returned object.
646
+ *
585
647
  * `mode: 'service-worker'` is a different shape entirely: it hosts a
586
648
  * path → content map behind a real, same-origin, HTTP-shaped scope
587
649
  * instead of evaluating code — see andbox#14 and README's Security model
@@ -590,6 +652,7 @@ export interface ServiceWorkerSandbox {
590
652
  * @param options Sandbox configuration options.
591
653
  * @returns A promise that resolves to the sandbox instance.
592
654
  */
655
+ export declare function createSandbox(options: IframeSandboxOptions): Promise<IframeSandbox>;
593
656
  export declare function createSandbox(options?: SandboxOptions): Promise<Sandbox>;
594
657
  export declare function createSandbox(
595
658
  options: ServiceWorkerSandboxOptions,
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,7 @@ 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';
20
21
 
21
22
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
22
23
 
@@ -305,15 +306,21 @@ async function createServiceWorkerSandbox(options = {}) {
305
306
  * @property {string} [baseURL] - Base URL for relative imports
306
307
  * @property {import('./capability-gate.mjs').GatePolicy} [policy] - Rate limiting policy
307
308
  * @property {(level: string, ...args: string[]) => void} [onConsole] - Console output handler
309
+ * @property {Element} [container] - mode: 'iframe': element the frame is appended to (default: offscreen in document.body)
310
+ * @property {string} [html] - mode: 'iframe': initial body markup
311
+ * @property {string} [csp] - mode: 'iframe': Content-Security-Policy for the frame
312
+ * @property {string[]} [iframeSandbox] - mode: 'iframe': extra sandbox tokens ('allow-same-origin' needs dangerouslyAllowSameOrigin)
313
+ * @property {boolean} [dangerouslyAllowSameOrigin] - mode: 'iframe': permit 'allow-same-origin' (removes the origin boundary)
314
+ * @property {(iframe: HTMLIFrameElement) => void} [onFrame] - mode: 'iframe': called with every new frame before it is attached
308
315
  */
309
316
 
310
- const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'inline', 'data-uri', 'service-worker'];
317
+ const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'iframe', 'inline', 'data-uri', 'service-worker'];
311
318
 
312
319
  /**
313
320
  * Create a new sandboxed runtime.
314
321
  *
315
322
  * @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 }>}
323
+ * @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
324
  */
318
325
  export function createSandbox(options = {}) {
319
326
  if (options.untrusted === true) {
@@ -342,7 +349,7 @@ export function createSandbox(options = {}) {
342
349
  if (mode === 'inline') return createInlineSandbox(options);
343
350
  if (mode === 'data-uri') return createDataUriSandbox(options);
344
351
  if (mode === 'service-worker') return createServiceWorkerSandbox(options);
345
- return createWorkerSandbox(options, mode === 'node-worker', mode === 'wasm');
352
+ return createWorkerSandbox(options, mode);
346
353
  }
347
354
 
348
355
  /** Default limits for `mode: 'wasm'` (0 = unlimited / not enforced). */
@@ -408,7 +415,18 @@ async function resolveWasmConfig(options, baseURL, usingNode) {
408
415
  };
409
416
  }
410
417
 
411
- async function createWorkerSandbox(options = {}, forceNode = false, isWasm = false) {
418
+ /**
419
+ * Worker-shaped sandboxes: `worker`, `node-worker`, `wasm` and `iframe`. They
420
+ * share one host implementation; `iframe` swaps the Worker for a sandboxed
421
+ * `<iframe>` adapter (src/iframe-host.mjs) behind the same interface.
422
+ *
423
+ * @param {object} options
424
+ * @param {'worker' | 'node-worker' | 'wasm' | 'iframe'} [kind]
425
+ */
426
+ async function createWorkerSandbox(options = {}, kind = 'worker') {
427
+ const forceNode = kind === 'node-worker';
428
+ const isWasm = kind === 'wasm';
429
+ const isIframe = kind === 'iframe';
412
430
  const {
413
431
  importMap = { imports: {}, scopes: {} },
414
432
  capabilities = {},
@@ -433,6 +451,18 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
433
451
  // default selects it only when there is no global Worker under Node.
434
452
  let workerFactory = options.workerFactory || null;
435
453
  let usingNode = false;
454
+ // mode: 'iframe' -- a sandboxed, opaque-origin <iframe> instead of a Worker.
455
+ let iframeHost = null;
456
+ if (isIframe) {
457
+ if (options.workerFactory || nodeWorker) {
458
+ throw new Error("workerFactory and nodeWorker do not apply to mode: 'iframe'.");
459
+ }
460
+ iframeHost = createIframeFactory(
461
+ normalizeIframeOptions(options),
462
+ defaultTimeoutMs > 0 ? defaultTimeoutMs : DEFAULT_TIMEOUT_MS
463
+ );
464
+ workerFactory = iframeHost.factory;
465
+ }
436
466
  if (!workerFactory && (forceNode || (typeof Worker === 'undefined' && isNodeRuntime()))) {
437
467
  workerFactory = await createNodeWorkerFactory(nodeWorker);
438
468
  usingNode = true;
@@ -494,7 +524,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
494
524
 
495
525
  function createWorker() {
496
526
  workerAbort = new AbortController();
497
- const source = isWasm ? makeWasmWorkerSource() : makeWorkerSource();
527
+ const source = isWasm ? makeWasmWorkerSource() : isIframe ? makeIframeRuntimeSource() : makeWorkerSource();
498
528
  if (workerFactory) {
499
529
  worker = workerFactory(source);
500
530
  attachWorkerHandlers();
@@ -817,11 +847,21 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
817
847
  endOp();
818
848
  }
819
849
 
820
- return {
850
+ const sandbox = {
821
851
  evaluate,
822
852
  defineModule,
823
853
  dispose,
824
854
  stats,
825
855
  isDisposed: () => disposed,
826
856
  };
857
+ if (iframeHost) {
858
+ // The live frame. A timeout, abort or unload replaces it with a new
859
+ // element (same container position and attributes; `onFrame` is called
860
+ // for every new one), and it is null after dispose().
861
+ Object.defineProperty(sandbox, 'iframe', {
862
+ enumerable: true,
863
+ get: () => (disposed ? null : iframeHost.current()),
864
+ });
865
+ }
866
+ return sandbox;
827
867
  }
@@ -26,6 +26,32 @@
26
26
  * @returns {string} The Worker script source code.
27
27
  */
28
28
  export function makeWorkerSource() {
29
+ return makeRuntimeSource({ lockdown: true });
30
+ }
31
+
32
+ /**
33
+ * The shared runtime behind `makeWorkerSource()` (Worker) and `mode: 'iframe'`.
34
+ *
35
+ * The script talks to the host through a `self`-shaped object with
36
+ * `postMessage`, `close` and an `onmessage` setter: the real Worker global
37
+ * scope, or (iframe mode) a wrapper around a MessagePort the frame was handed.
38
+ *
39
+ * @param {{ lockdown?: boolean }} [options]
40
+ * `lockdown` (default true) deletes and shadows the Worker's ambient
41
+ * network/worker globals (andbox#10). The iframe runtime turns it off: there
42
+ * the browser's opaque-origin boundary is the isolation, and evaluated code is
43
+ * meant to have its frame's `window`/`document`.
44
+ * @returns {string}
45
+ */
46
+ export function makeRuntimeSource({ lockdown = true } = {}) {
47
+ const lockedGlobals = lockdown
48
+ ? `[
49
+ 'fetch', 'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource',
50
+ 'Worker', 'SharedWorker', 'importScripts', 'indexedDB', 'caches', 'BroadcastChannel',
51
+ 'postMessage', 'self',
52
+ ]`
53
+ : '[]';
54
+ const shadowed = lockdown ? "[...LOCKED_GLOBALS, 'window']" : '[]';
29
55
  return `
30
56
  'use strict';
31
57
 
@@ -48,11 +74,7 @@ try { delete globalThis.__andboxNodeVirtual; } catch {}
48
74
  const scope = self;
49
75
  const post = self.postMessage.bind(self);
50
76
  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
- ];
77
+ const LOCKED_GLOBALS = ${lockedGlobals};
56
78
  for (const k of LOCKED_GLOBALS) {
57
79
  try { delete globalThis[k]; } catch {}
58
80
  if (k in globalThis) {
@@ -61,7 +83,7 @@ for (const k of LOCKED_GLOBALS) {
61
83
  }
62
84
  // Names shadowed lexically for evaluated code as well (covers environments
63
85
  // where a global could not be deleted).
64
- const SHADOWED = [...LOCKED_GLOBALS, 'window'];
86
+ const SHADOWED = ${shadowed};
65
87
 
66
88
  // ── Remote import policy (andbox#7) ──
67
89
  function assertImportAllowed(href) {