Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/specs/dor-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Source of truth: `createPlaywrightProvider` in `lib/src/host/playwright-host.ts`

`dor iframe <url>` frames the page's own DOM — zero-lag for human inspection, but agents cannot drive or read it. On hosts with `createIframeProxyUrl`, `IframePanel` frames a per-grant loopback proxy URL; without it, a raw uninstrumented iframe. The desktop playground's `createIframeProxyUrl` fronts only its own viewers (`docs/specs/tutorial.md` → Playground filesystem).

The proxy instruments any `http://` upstream, loopback and remote alike: headers are rewritten per the table below and the shim injected into HTML; HTTP and WebSocket traffic passes through. A site's "do not embed" is overridden, not obeyed (rationale); the sandbox neutralizes JS framebusting. **Must connect a grant, over HTTP and WebSocket alike, only to the addresses its upstream resolved to when minted.** **Must refuse (`scheme`) an upstream resolving to any link-local / cloud-metadata address**, in any spelling; one that does not resolve is `unreachable`.
The proxy instruments any `http://` upstream it fronts, loopback and remote alike — a remote one only once allowed ([Iframe External Origins](#iframe-external-origins)): headers are rewritten per the table below and the shim injected into HTML; HTTP and WebSocket traffic passes through. A site's "do not embed" is overridden, not obeyed (rationale); the sandbox neutralizes JS framebusting. **Must connect a grant, over HTTP and WebSocket alike, only to the addresses its upstream resolved to when minted.** **Must refuse (`scheme`) an upstream resolving to any link-local / cloud-metadata address**, in any spelling; one that does not resolve is `unreachable`.

**Must refuse `https://`, regardless of scheme casing, at every entry to the iframe renderer on a host with the proxy** (a host without it frames https raw):

Expand All @@ -276,7 +276,7 @@ Header rewriting beyond `Origin`, `Cookie`, `Set-Cookie`, and the framing header
| request | `Accept-Encoding` | deleted on a document load (`Sec-Fetch-Dest` `document`, `iframe`, `frame`, `embed`, `object`, or none sent), so its HTML comes back identity; kept on every other request |
| response | `X-Dormouse-Preserve-CSP: 1` | consumed; preserves upstream CSP headers and meta policies |
| response | hop-by-hop (RFC 7230 §6.1) | dropped |
| response | `Location` | an exact upstream origin rewritten back to the proxy origin, so a redirect stays inside the proxy |
| response | `Location` | an exact upstream origin rewritten back to the proxy origin, so a redirect stays inside the proxy; any other origin on a frame load is [Iframe External Origins](#iframe-external-origins) |
| response | `Vary` | `Sec-Fetch-Dest` appended, since the `Accept-Encoding` sent upstream depends on it |
| response | `Clear-Site-Data` | `"cache", "storage"` on a freshly minted grant's first frame load only: its port may have fronted another upstream |
| response body | `<meta http-equiv="content-security-policy">` | removed unless the response opts into CSP preservation |
Expand All @@ -291,6 +291,19 @@ Current limits: absolute-origin subresources (`http://localhost:5173/...`, `ws:/

Source of truth: `lib/src/components/wall/IframePanel.tsx`, `lib/src/host/iframe-proxy.ts`, `lib/src/host/iframe-proxy-rewrite.ts` (`instrumentHtml`, `isBlockedAddress`), `iframeRefusal` in `lib/src/components/wall/browser-url.ts`.

### Iframe External Origins

An upstream is local when every address it resolved to at mint reaches this machine: loopback (127.0.0.0/8, `::1`), `0.0.0.0`, `::`, or an address this host's interfaces hold; its name never counts.

- A non-local upstream is refused (`external`) unless the request carries `allowExternal`, sent only for an origin on the panel's allowlist (`docs/specs/security-local.md` -> "Browser panes").
- The allowlist holds exact origins (scheme, host, port) in webview `localStorage`, one per host install, each removable in Settings → General. **Must re-ask the host when the pane's origin is allowed or removed**, so a removal closes an open preview.
- The `external` warning names the origin and offers Open in browser, Allow this origin, and Cancel (back one entry, else close the pane).
- An allowed origin gets a preview only: cookies stay stripped and `https://` stays refused.

Both webview CSPs admit only loopback frames (`docs/specs/security-local.md` -> "Network policy"), so a form, script, or link off the proxy to a remote origin is refused. A frame load (`Sec-Fetch-Dest` `document` or `iframe`) redirected to another origin, given an embedder chain, gets a Dormouse page in place of the `Location`. In the pane's frame it posts `navigate` with the target and its own URL; the panel takes it as a URL-bar entry, replacing the redirecting history entry (rationale). A nested frame, outside the webview CSP, follows the redirect itself.

Source of truth: `mintGrant` and `offOriginRedirect` in `lib/src/host/iframe-proxy.ts`, `errorPageHtml` in `lib/src/host/iframe-proxy-rewrite.ts`, `lib/src/lib/iframe-allowlist.ts`, `lib/src/components/wall/IframePanel.tsx`.

### Iframe Proxy Leases

**Must take every mounted `IframePanel`'s grant under a lease** it mints, held under an owner the host transport names (VS Code router, standalone window label), never the webview.
Expand All @@ -305,7 +318,9 @@ Source of truth: `createIframeProxyUrl` and `releaseIframeProxyLease` in `lib/sr

**Must send only fixed shim message kinds to the app** — `leader`, `pointerdown`, `location`, `open-window`, `theme-request`; `location` carries `loaded: true` only on the document's own `pageshow`/`DOMContentLoaded` report. **Must relay only `leader`, `pointerdown`, and `open-window` from nested documents.** `open-window` intercepts nonempty anchor targets other than `_self`, except links with `download`, plus `window.open`. Theme delivery is `docs/specs/theme.md` → Tool iframe themes; `theme-request` stays in the outer document.

**Must let only `http:` and `https:` reach a browser Surface, re-checked at the sink**: `open-window` and the control socket's `surface.iframe` go through `browserSurfaceUrl`, and `IframePanel` checks `params.url` again before framing it, since the header's URL editor writes there too (rationale).
The proxy's redirect page also posts `navigate` ([Iframe External Origins](#iframe-external-origins)), which any proxied document can forge.

**Must let only `http:` and `https:` reach a browser Surface, re-checked at the sink**: `open-window`, `navigate`, and the control socket's `surface.iframe` go through `browserSurfaceUrl`, and `IframePanel` checks `params.url` again before framing it, since the header's URL editor writes there too (rationale).

Parent listeners check the message origin against live proxy grants (`docs/specs/security-local.md` -> "Browser panes"). A leader message exits passthrough like an in-document dual-tap; a `location` maps back to its upstream URL for chrome and history without reloading the frame; a new-tab request asks before opening an adjacent browser pane.

Expand All @@ -326,7 +341,7 @@ The optional `PlatformAdapter.createIframeProxyUrl`, the `IframeProxyResult` uni

**Must pass the webview's own ancestor chain with every request for a proxy URL** — `location.origin` plus `location.ancestorOrigins`, knowable only in the realm that has a `location` (rationale). The host validates it and uses it all-or-nothing: an unparseable or opaque (`"null"`) entry means no chain (rationale), and with no chain the proxy preserves framing headers and injects nothing.

The proxy binds loopback only, a mitigation and not the boundary; each grant fronts exactly one upstream; no user script is injected; and every user-supplied `http://` target but one resolving to a refused address ([Iframe Renderer](#iframe-renderer)) is trusted as the user's command, at the cost of the upstream's own XSS policy unless it opts into preservation. The `Host`, `Origin`, cookie, `frame-ancestors`, shim-targeting and grant-refresh rules are `docs/specs/security-local.md` → "Loopback Listeners" and "Browser panes".
The proxy binds loopback only, a mitigation and not the boundary; each grant fronts exactly one upstream; no user script is injected; and every user-supplied `http://` target resolving to loopback, or allowed off this machine ([Iframe External Origins](#iframe-external-origins)), but none resolving to a refused address ([Iframe Renderer](#iframe-renderer)), is trusted as the user's command, at the cost of the upstream's own XSS policy unless it opts into preservation. The `Host`, `Origin`, cookie, `frame-ancestors`, shim-targeting and grant-refresh rules are `docs/specs/security-local.md` → "Loopback Listeners" and "Browser panes".

Source of truth: `lib/src/lib/platform/iframe-proxy-types.ts`, `embedderOrigins` in `lib/src/lib/embedder-origins.ts`, `normalizeEmbedderOrigins` in `lib/src/host/iframe-proxy-rewrite.ts`, `lib/src/host/loopback-guard.ts`. `lib/src/host/iframe-proxy.test.ts` pins the upgrade path as well as the request path.

Expand All @@ -341,6 +356,8 @@ A moved iframe Surface remounts at its saved URL after consent: `docs/specs/layo
- General per-surface teardown hook for future Dormouse-owned backend processes; agent-browser surfaces already dispose their controller on kill/swap, and iframe views release their leases.
- Process-backed targets are owned by the **dor-tools** scope (`docs/specs/dor-tool.md` `## Future`), which subsumes the plugin/backend target axis formerly staged here.
- Optional terminal-side "this port is viewed by surface:N" indicator.
- Following an off-proxy link click or form submission in place through the [Iframe External Origins](#iframe-external-origins) gate, where the webview CSP refuses it today.
- Cookie support for authenticated local tools, scoped from stories of real tools that fail; a jar must authorize its caller before attaching stored credentials.
### Daemon-owned crisp captures

Replace spawn-per-shot CLI screenshots only with a daemon-owned capture channel or an upstream answer exposing current viewport and DPR. **Must retain device-resolution output and external viewport/device changes without adding a second viewport writer.** The host cannot reconstruct an externally chosen DPR from screencast frames; a host-owned CDP metrics override is safe only while sync-to-pane owns the values. (rationale)
12 changes: 12 additions & 0 deletions docs/specs/dor-browser.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,18 @@ The built-in local-file viewer supplies its own content boundary and permits the

**Why a grant gets its own origin instead of a path token.** A dedicated origin keeps root-relative resources and client-side routers working with no body URL rewriting; a path token would have to survive every link, redirect and `fetch` the page makes.

## Iframe External Origins

**Why the proxy changes what an external page is.** It strips the upstream's framing headers and, by default, its CSP, and serves every grant from the shared `127.0.0.1` hostname, so a remote page loses protections its author relied on; the warning names that, rather than claiming the page itself is hostile. Approval does not make signing in safe, which is why cookies stay stripped.

**Why local is the resolved address.** A hostname proves nothing: `localhost` is whatever the resolver answers, a public name can resolve to `127.0.0.1`, and a LAN or tailnet name is usually another machine. The addresses are the ones the grant is pinned to, so the one classified is the one connected to. Two non-loopback answers still reach this machine and are handed out routinely: `0.0.0.0`, which `python -m http.server` and other servers bound to every interface print, and an address one of this host's interfaces holds, which `listenerUrlsByPort` names for a server bound to that interface alone. A private-network address on any other host stays external.

**Why the grant is the only gate needed.** Both webview CSPs already limit `frame-src` to loopback, so the browser refuses any navigation off the proxy origin to a remote one before its request; only a minted grant can put a remote page in the pane, and the panel asks for one on every URL it frames. The one hop the browser never sees is a redirect, which the proxy follows on the frame's behalf, so the proxy answers it. Before this, a foreign `Location` was forwarded and the frame showed a blank pane (`docs/specs/vscode.md` → CSP policy).

**Why the redirect replaces the redirecting entry.** Pushing the target would leave the redirecting URL behind it, and Back would land there only to be redirected again. The redirect page carries no shim for the same reason: its `location` report would race the `navigate` and could put the redirecting URL back.

**Why one list per install.** Every other Settings value persists per host install in `localStorage`; a per-window or per-session list would re-ask on every restore of a pane the user already allowed.

## Iframe Proxy Leases

**Why a grant's life is its view's, not its traffic's.** Reproduced against the proxy (2026-09-24, then again as the failing tests that pin this, 2026-10-03): a live Vite pane idle for five minutes lost its grant on the next grant created anywhere — its HMR socket survived, module fetches then failed with `ECONNREFUSED` — and 33 grant creations anywhere evicted a live pane under the 32-grant cap. Every Reload, Back or Forward minted a new port, which is a new origin, so the app's localStorage, IndexedDB and service worker went with it.
Expand Down
3 changes: 3 additions & 0 deletions docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Notification text: `docs/specs/alert.md` -> "Text And Security".
| Guarantee | Feature | Pinned by |
| --- | --- | --- |
| **A page in a browser pane cannot forge a host message.** In VS Code every host message carries a per-boot token it cannot read, the standalone adapters have no inbox for it to post to, and every other listener it can reach checks the sender. | | `lib/src/lib/platform/vscode-adapter.test.ts`, `standalone/src/sidecar-adapters.test.ts`, `lib/src/lib/webview-message-listeners.test.ts` |
| **An iframe preview shows a website from another machine only after you allow its exact origin.** A redirect off the page's origin asks the same way; each allowed origin is listed in Settings, where removing it closes its open previews. | | `lib/src/host/iframe-proxy.test.ts`, `lib/src/components/wall/IframePanel.test.tsx`, `vscode-ext/test/message-router.test.ts` |

The attacker is the page inside a browser pane.

Expand All @@ -62,6 +63,8 @@ Where Windows screenshots and pasted images land is a known gap (`docs/specs/sec
- **FAIL IF** `S137` a standalone module registers a window `message` listener, or a standalone adapter takes a host event from anything but its transport. Pinned by `lib/src/lib/webview-message-listeners.test.ts` and `standalone/src/sidecar-adapters.test.ts`.
- **FAIL IF** `S138` a browser pane's frame, proxied or raw, carries a `sandbox` token beyond `allow-scripts`, `allow-same-origin`, `allow-forms`, `allow-popups`, `allow-modals`, and `allow-downloads` — an `allow-top-navigation` form lets a page navigate the Wall away — or an `allow` feature beyond `autoplay`, `clipboard-write`, and `fullscreen`: `IFRAME_SANDBOX` and `IFRAME_ALLOW` in `lib/src/components/wall/IframePanel.tsx`. Pinned by `lib/src/components/wall/IframePanel.test.tsx`.

- **FAIL IF** `S294` the iframe proxy mints a grant, or a lease reuses one, for an upstream any of whose pinned addresses is off this machine, as `docs/specs/dor-browser.md` -> "Iframe External Origins" defines it, without the request's `allowExternal` being exactly `true`, or judges that from the hostname rather than those addresses; or a host forwards anything else as `true`; or the panel sends it for an origin not on its allowlist; or a frame load redirected off its grant's upstream origin reaches a frame with an embedder chain as a `Location` rather than the redirect page whose `navigate` the panel re-gates. The proxy strips a remote page's framing and script protections (`docs/specs/dor-browser.md` -> "Iframe External Origins"). Read `mintGrant`, `acquireLeased`, and `offOriginRedirect` in `lib/src/host/iframe-proxy.ts`, `isThisMachineAddress` in `lib/src/host/iframe-proxy-rewrite.ts`, the `allowExternal` hops in `vscode-ext/src/message-router.ts`, `standalone/sidecar/main.js`, and `standalone/src-tauri/src/lib.rs`, and the `createProxy` call and `navigate` handler in `lib/src/components/wall/IframePanel.tsx`. Pinned by `lib/src/host/iframe-proxy.test.ts`, `lib/src/components/wall/IframePanel.test.tsx`, and `vscode-ext/test/message-router.test.ts`.

Source of truth: `isProxyOrigin` in `lib/src/lib/iframe-proxy-registry.ts`, the per-panel check in `lib/src/components/wall/IframePanel.tsx`; `lib/src/host/private-capture-dir.ts`, `standalone/sidecar/clipboard-ops.js`, `standalone/src-tauri/src/clipboard_win.rs`.

## The dor control socket
Expand Down
2 changes: 2 additions & 0 deletions dor/src/commands/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const iframeCommand: Command = {

Agents cannot read or drive an iframe surface, and it drops the page's cookies, so logins do not work in it. For those, and for any https:// page, which the iframe refuses, use \`dor agent-browser open <url>\`.

A page served from anywhere but this machine opens behind a warning until you allow its origin there.

If the caller surface is an untouched terminal, Dormouse replaces that terminal with the iframe. Otherwise Dormouse creates a split next to the caller/focused surface.

The target is one of:
Expand Down
2 changes: 2 additions & 0 deletions dor/test/snapshots/help/iframe.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Opens an http:// page in a high-fidelity iframe surface for a human to look at.

Agents cannot read or drive an iframe surface, and it drops the page's cookies, so logins do not work in it. For those, and for any https:// page, which the iframe refuses, use `dor agent-browser open <url>`.

A page served from anywhere but this machine opens behind a warning until you allow its origin there.

If the caller surface is an untouched terminal, Dormouse replaces that terminal with the iframe. Otherwise Dormouse creates a split next to the caller/focused surface.

The target is one of:
Expand Down
32 changes: 32 additions & 0 deletions lib/src/components/IframeAllowlistSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from 'react';
import { XIcon } from '@phosphor-icons/react';
import { modalIconButton, SETTINGS_SECTION } from './design';
import { getIframeAllowlist, removeIframeOrigin, subscribeToIframeAllowlist } from '../lib/iframe-allowlist';

/** The origins an iframe preview may show off this machine, each removable;
* they are added from the pane's warning (docs/specs/dor-browser.md → "Iframe
* External Origins"). Nothing to show until one is. */
export function IframeAllowlistSettings({ hidden }: { hidden: boolean }) {
const origins = useSyncExternalStore(subscribeToIframeAllowlist, getIframeAllowlist);
if (origins.length === 0) return null;
return (
<section data-setting="previews" hidden={hidden} className={SETTINGS_SECTION}>
<div className="text-sm text-foreground">External websites allowed in iframe previews:</div>
<ul className="mt-1.5 flex flex-col gap-0.5">
{origins.map((origin) => (
<li key={origin} className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate font-mono text-sm text-foreground" title={origin}>{origin}</span>
<button
type="button"
aria-label={`Remove ${origin}`}
className={modalIconButton()}
onClick={() => removeIframeOrigin(origin)}
>
<XIcon size={12} weight="bold" />
</button>
</li>
))}
</ul>
</section>
);
}
Loading
Loading