From 98e7e2729e0f11b03eedd74963b1d5e26653b18a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 13:29:34 -0700 Subject: [PATCH 1/4] Make iframe previews local-first, with an external-origin warning and allowlist The iframe proxy fronted any http:// upstream it was handed, stripping a remote site's framing headers and CSP and serving it from the shared loopback hostname, with nothing telling the user that a remote page had lost the protections its author relied on. And a frame load redirected off the grant's upstream origin was forwarded verbatim, so the webview's loopback-only frame-src refused it as a blank pane. The grant is the only chokepoint that matters: both webview CSPs admit only loopback frames, so a remote page reaches a pane only through a minted grant. So the proxy now classifies an upstream by the addresses the grant is pinned to (every one loopback, never the hostname) and refuses a non-local one as `external` unless the request says the user allowed its exact origin; a leased external grant is not reused once the allowance is withdrawn. Each host forwards only an exact `allowExternal: true`. The panel shows an "External website preview" warning with Open in browser, Allow this origin, and Cancel. Allowed origins persist per host install in localStorage, listed and removable in Settings -> General; allowing or removing one re-asks the host, so a removal closes an open preview. A frame load redirected to another origin is answered with a Dormouse page that posts `navigate` to the app, which takes it as a URL-bar entry through the same gate, replacing the redirecting history entry; a frame nested in a proxied page follows the redirect itself, outside the webview CSP. Cookies stay stripped and https:// stays refused; in-place following of off-proxy links and forms, and cookie support, stay under ## Future. Closes #572. Co-Authored-By: Claude Opus 5.5 --- docs/specs/dor-browser.md | 25 +++- docs/specs/dor-browser.rationale.md | 12 ++ dor/src/commands/iframe.ts | 2 + dor/test/snapshots/help/iframe.md | 2 + .../components/IframeAllowlistSettings.tsx | 32 +++++ lib/src/components/SettingsDialog.test.tsx | 13 ++ lib/src/components/SettingsDialog.tsx | 4 +- lib/src/components/wall/IframePanel.test.tsx | 114 ++++++++++++++++++ lib/src/components/wall/IframePanel.tsx | 101 +++++++++++++++- lib/src/host/iframe-proxy-rewrite.ts | 39 +++++- lib/src/host/iframe-proxy.test.ts | 80 ++++++++++++ lib/src/host/iframe-proxy.ts | 74 ++++++++++-- lib/src/lib/iframe-allowlist.ts | 70 +++++++++++ lib/src/lib/platform/fake-adapter.ts | 4 +- lib/src/lib/platform/iframe-proxy-types.ts | 9 +- lib/src/lib/platform/types.ts | 10 +- lib/src/lib/platform/vscode-adapter.ts | 8 +- scripts/spec-word-budgets.json | 2 +- standalone/scripts/dev-agent-browser.mjs | 5 +- standalone/sidecar/main.js | 1 + standalone/src-tauri/src/lib.rs | 4 + standalone/src/browser-sidecar-adapter.ts | 4 +- standalone/src/tauri-adapter.ts | 4 +- vscode-ext/src/iframe-proxy-host.ts | 3 +- vscode-ext/src/message-router.ts | 1 + vscode-ext/src/message-types.ts | 2 +- vscode-ext/test/message-router.test.ts | 16 +++ 27 files changed, 603 insertions(+), 38 deletions(-) create mode 100644 lib/src/components/IframeAllowlistSettings.tsx create mode 100644 lib/src/lib/iframe-allowlist.ts diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index b88e78c2a..27e26ef0a 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -255,7 +255,7 @@ Source of truth: `createPlaywrightProvider` in `lib/src/host/playwright-host.ts` `dor iframe ` 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): @@ -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 | `` | removed unless the response opts into CSP preservation | @@ -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 is loopback (127.0.0.0/8, IPv4-mapped included, or `::1`); its name never counts. + +- **Must refuse (`external`) to mint or reuse a grant for a non-local upstream unless the request's `allowExternal` is exactly `true`**, sent only for an origin on the panel's allowlist. +- 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. **Must answer a frame load (`Sec-Fetch-Dest` `document` or `iframe`) redirected to another origin, given an embedder chain, with 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. @@ -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. @@ -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. @@ -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) diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 6a895de63..09c683d7c 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -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 another machine. The addresses are the ones the grant is pinned to, so the one classified is the one connected to. + +**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. diff --git a/dor/src/commands/iframe.ts b/dor/src/commands/iframe.ts index 048a36870..a14dd598b 100644 --- a/dor/src/commands/iframe.ts +++ b/dor/src/commands/iframe.ts @@ -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 \`. +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: diff --git a/dor/test/snapshots/help/iframe.md b/dor/test/snapshots/help/iframe.md index 2947b2fc3..d7d6f1415 100644 --- a/dor/test/snapshots/help/iframe.md +++ b/dor/test/snapshots/help/iframe.md @@ -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 `. +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: diff --git a/lib/src/components/IframeAllowlistSettings.tsx b/lib/src/components/IframeAllowlistSettings.tsx new file mode 100644 index 000000000..3747f0b20 --- /dev/null +++ b/lib/src/components/IframeAllowlistSettings.tsx @@ -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 ( + + ); +} diff --git a/lib/src/components/SettingsDialog.test.tsx b/lib/src/components/SettingsDialog.test.tsx index e1a90bb3b..b753f0402 100644 --- a/lib/src/components/SettingsDialog.test.tsx +++ b/lib/src/components/SettingsDialog.test.tsx @@ -15,6 +15,7 @@ import { stubResizeObserver } from './wall/wall-test-utils'; import { setNativeFieldValue } from '../lib/dom'; import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS } from '../lib/alert-settings'; import { resetPushDevices, setCommandWatched, getWatchedCommands, setPushDevices } from '../lib/terminal-registry'; +import { _resetIframeAllowlistForTesting, allowIframeOrigin, getIframeAllowlist } from '../lib/iframe-allowlist'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -182,6 +183,18 @@ describe('SettingsDialog navigation and search', () => { expect(getWatchedCommands()).not.toContain('my-agent'); }); + it('lists the external origins iframe previews may show, each removable, and nothing until one is', async () => { + _resetIframeAllowlistForTesting(); + await render(); + expect(document.querySelector('[data-setting="previews"]')).toBeNull(); + await act(async () => allowIframeOrigin('http://docs.example')); + await search('docs.example'); + expect(visible('[data-setting]').map((node) => node.dataset.setting)).toEqual(['previews']); + await act(async () => visible('button[aria-label="Remove http://docs.example"]')[0].click()); + expect(getIframeAllowlist()).toEqual([]); + expect(document.querySelector('[data-setting="previews"]')).toBeNull(); + }); + it('keeps a topic chosen while the opened-at topic settles', async () => { await act(async () => root.render( {}} initialTopic="network" />)); await act(async () => {}); diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index 884f462ac..1ad86cb5f 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -18,6 +18,7 @@ import { import { ThemePicker } from './ThemePicker'; import { ShellPicker } from './ShellPicker'; import { WatchedCommandList } from './WatchedCommandList'; +import { IframeAllowlistSettings } from './IframeAllowlistSettings'; import { NetworkPhones, NetworkSettings, NetworkUpdates } from './NetworkSettings'; import { hostOf, useNetworkPolicy } from './remote-control-shared'; import { PushTestButton, SpeakTestButton } from './AlarmTestButtons'; @@ -117,7 +118,7 @@ function describeRefusal(status: BurrowConsoleStatus, remoteControlBelow: boolea } const TOPICS = [ - { id: 'general', label: 'General', icon: GearIcon, groups: ['theme', 'shell'] }, + { id: 'general', label: 'General', icon: GearIcon, groups: ['theme', 'shell', 'previews'] }, { id: 'notifications', label: 'Notifications', icon: BellIcon, groups: ['speech', 'push', 'watcher', 'inactivity'] }, { id: 'network', label: 'Network', icon: GlobeIcon, groups: ['network', 'phones', 'updates'] }, { id: 'labs', label: 'Labs', icon: FlaskIcon, groups: ['delayedKill'] }, @@ -456,6 +457,7 @@ export function SettingsDialog({ onClose, initialTopic }: { onClose: () => void; )} +