Skip to content
Merged
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
9 changes: 6 additions & 3 deletions docs/specs/dor-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,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 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`.
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, including an IPv6 literal that reaches one through a translator (rationale); 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,13 +276,16 @@ Header rewriting beyond `Origin`, `Cookie`, `Set-Cookie`, and the framing header
| request | `Host` | upstream host |
| request | `Referer` | an exact parsed proxy origin replaced with the upstream origin, path and query kept |
| 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 |
| request | `Service-Worker: script` or `Sec-Fetch-Dest: serviceworker` | answered `403` without reaching the upstream, on every grant but a tunneled one |
| 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; 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; never on a tunneled grant, whose page is not at that port |
| response | `Clear-Site-Data` | `"cache", "storage"` on a freshly minted grant's first frame load only, clearing what another upstream on its port left; never on a tunneled grant, whose page is not at that port |
| response body | `<meta http-equiv="content-security-policy">` | removed unless the response opts into CSP preservation |

**Must keep every service worker off a non-tunneled grant's origin**, so none survives its grant to answer the next one on that port ahead of the proxy and its `Clear-Site-Data` (rationale). A page previews without its worker.

**Must instrument only an identity-encoded, ASCII-compatible HTML body, keeping its `content-type` as sent**, charset included; a compressed body, or a UTF-16 one (any WHATWG label, or a byte-order mark), passes through uninstrumented (rationale), except that a tunneled grant decodes every compressed body first (`docs/specs/remote-api.md` -> "Frames"). **Never place the shim ahead of the doctype, a `<meta charset>` or a UTF-8 BOM.**

**Must preserve enforced and report-only CSP verbatim when the upstream response sends `X-Dormouse-Preserve-CSP: 1`**, for every MIME type, adding the validated ancestor policy separately; opted-in policies may still prevent framing or shim execution (rationale). **Never infer this opt-in from request headers.**
Expand Down Expand Up @@ -324,7 +327,7 @@ The proxy's redirect page also posts `navigate` ([Iframe External Origins](#ifra

**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.
Parent listeners check the message origin against live proxy grants (`docs/specs/security-local.md` -> "Browser panes", which also bounds `pointerdown`). A `pointerdown` from the frame holding focus enters its pane like a click; the shim focuses its own frame before posting one, since a browser gives a page that cancels a press no focus (rationale). 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.

A proxied frame whose shim has reported, then loads a document with no `location` report soon after, shows that document as uninstrumented (not HTML, off the proxy, refused, or its grant gone); a new frame source waits for its first report again (rationale).

Expand Down
8 changes: 7 additions & 1 deletion docs/specs/dor-browser.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ The built-in local-file viewer supplies its own content boundary and permits the

**Why the HTML path keeps the body's own encoding.** Each was reproduced against the proxy (2026-09-23): relabelling every HTML response `charset=utf-8` overrode both the upstream header and any `<meta charset>`, so a Shift_JIS or windows-1252 page mis-decoded; an upstream that compresses without being asked had the shim prepended to its gzip bytes with `content-encoding: gzip` kept, and the frame failed with `ERR_CONTENT_DECODING_FAILED`; and a valid document with neither `</head>` nor `<body>` got the shim before `<!doctype html>`, switching it to quirks mode. Deleting `Accept-Encoding` on every request sent a remote upstream's scripts and styles uncompressed, typically 3-5x the bytes.

**Which addresses a metadata refusal names.** Fixed endpoints outside 169.254/16 are matched as single addresses, since each sits in a block that also holds ordinary machines. The check reads IPv6 by form rather than by a list of spellings: mapped and the deprecated compatible form name the IPv4 address itself, and translated and NAT64 end an IPv6 connection at an IPv4 service through a translator. Tunnels (6to4, Teredo, ISATAP) are not read: they carry IPv6 inside IPv4 to the embedded address (RFC 3056, RFC 4380, RFC 5214), which has to decapsulate and speak IPv6 itself, so no TCP connection to an HTTP metadata endpoint forms. A deployment can choose its own NAT64 prefix, so any /96 ending in zeros counts, at the cost of refusing a host whose static address happens to read that way; a chosen prefix of another length, whose address carries no mark of it, is not read.

**Why a preview has no service worker.** Measured 2026-11 on Chromium 156 and a macOS 27 WKWebView, both of which run workers on a loopback origin: a registered worker stays with the origin after its grant ends, and answers a later grant on the same port before the request reaches the proxy, so that grant's `Clear-Site-Data` never arrives. Only the proxy sees every registration, since the browser names the script fetch itself and a page cannot change that. The cost is that a local PWA previews without its worker. A tunneled grant is exempt because its page lives at the frame origin on the Client.

**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
Expand All @@ -208,7 +212,7 @@ The built-in local-file viewer supplies its own content boundary and permits the

**Why the host names the owner.** A webview that reloads or a window that closes never releases what its last page held; owner-scoped release at reinitialization and at the end of the owner reclaims those. Were the owner the webview's word, one webview could release another's views.

**Why a fresh grant clears site data.** A port freed by one grant can be handed by the OS to the next, for another upstream, while the browser still keys the first upstream's storage and service worker on `http://127.0.0.1:<port>`. Only a freshly minted grant's first frame load clears it, so a reused grant keeps its page's storage, and a probe that names no destination cannot spend it. It is a mitigation, not a guarantee: a cache-first service worker the old upstream left can answer the navigation before the proxy sees it, and whether WebKit webviews honor the header on a loopback origin is unverified.
**Why a fresh grant clears site data.** A port freed by one grant can be handed by the OS to the next, for another upstream, while the browser still keys the first upstream's storage and service worker on `http://127.0.0.1:<port>`. Only a freshly minted grant's first frame load clears it, so a reused grant keeps its page's storage, and a probe that names no destination cannot spend it. It is a mitigation, not a guarantee: whether WebKit webviews honor the header on a loopback origin is unverified, and a worker registered before grants refused them can still answer ahead of it.

## Iframe Shim

Expand All @@ -218,6 +222,8 @@ The built-in local-file viewer supplies its own content boundary and permits the

**Why the panel checks again.** Every writer of `params.url` ends at the panel, and on a host with no proxy the raw fallback hands that string straight to `<iframe src>` under a sandbox that keeps `allow-same-origin`. Enumerating the writers is the fragile half: the header's URL editor was one the guarded callers did not cover, because `normalizeNavUrl` deliberately keeps a typed `javascript:` or `data:` scheme so the address bar can carry one. React blanks a `javascript:` `src` prop and nothing else, so `data:text/html,…` framed verbatim (reproduced in `IframePanel.test.tsx`, 2026-09) — a framework mitigation the code never claimed, for one scheme out of the set.

**Why the shim focuses its own frame for a press.** Measured 2026-11 on Chromium 156 and a macOS 27 WKWebView: a real press focuses the frame on its own, unless the page cancels `mousedown` or `pointerdown`, when focus stays where it was. The panel cannot make up for that, because the page can post `pointerdown` with no press. Calling `window.focus()` from the frame covers the cancelled press on both engines, and is no more than the page could call itself. Focus also reached the embedding document ahead of the message on every press: 200 of 200 each in out-of-process Chromium, one level and at VS Code's depth, and 40 of 40 in WKWebView, with the press cancelled or not.

**Why each shim hop has two explicit targets.** An injected document cannot tell whether its parent is the app or another document on the grant's proxy origin. It posts to both known origins; the browser delivers only the matching one. A same-origin parent reconstructs and relays only the three pane-level shapes upward; location is document-level, so only the outer document reports it. No wildcard or foreign origin enters the path.

## Iframe Focus And Rendering Notes
Expand Down
3 changes: 2 additions & 1 deletion docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The attacker is the page inside a browser pane.

Where Windows screenshots and pasted images land is a known gap (`docs/specs/security.md` -> "Known gaps").

**Every listener the webview realm exposes to a framed page checks the sender's origin before it acts** — `IframePanel` against its own panel's proxy origin, the Wall's leader channel against any live grant (`docs/specs/dor-browser.md` -> "Iframe Shim"). **That separates a proxied frame from any other, never the injected shim from the page it runs in** (rationale).
**Every listener the webview realm exposes to a framed page checks the sender's origin before it acts** — `IframePanel` against its own panel's proxy origin, the Wall's leader channel against any live grant (`docs/specs/dor-browser.md` -> "Iframe Shim"). **That separates a proxied frame from any other, never the injected shim from the page it runs in** (rationale), so a page can send every shim message with no gesture, and what bounds it is how far each one reaches. A `pointerdown` counts only from the frame already holding focus, so it never moves DOM focus or selection off another pane. A browser can still give a frame focus on the page's own call (`docs/specs/security.md` -> "Known gaps").

**A framed page cannot forge a *host* message.** The VS Code webview authenticates every host→webview message with a per-boot token (`docs/specs/vscode.md` -> "Webview message authentication"). **The standalone adapters have no forgeable inbox**: host events arrive over Tauri IPC or the dev harness's own event stream, never `window.postMessage`.

Expand All @@ -60,6 +60,7 @@ Where Windows screenshots and pasted images land is a known gap (`docs/specs/sec
- **FAIL IF** `S134` an injected shim targets anything but its proxy origin or the embedder chain's innermost origin, relays a nested `location`, a foreign-origin message, or an unregistered message, or the proxy uses a chain it did not validate in full: `iframeShim` and `normalizeEmbedderOrigins` in `lib/src/host/iframe-proxy-rewrite.ts`, applied in `lib/src/host/iframe-proxy.ts`. Pinned by `lib/src/host/iframe-proxy-rewrite.test.ts` and `lib/src/host/iframe-proxy.test.ts`.
- **FAIL IF** `S135` a `VSCodeAdapter` host-channel listener acts on a message before `isHostMessage` (`lib/src/lib/vscode-message-token.ts`) accepts it, or the token stops being minted per serve and attached only by `WebviewChannel.post` in `vscode-ext/src/webview-messaging.ts`: `dor:controlRequest` is one of the shapes a framed page could otherwise claim. The proxy-origin listeners above are guarded by origin, not the token. Pinned by `lib/src/lib/platform/vscode-adapter.test.ts`.
- **FAIL IF** `S136` any other webview `message` listener acts before checking its sender — `origin` against its proxy origin or a live grant, plus `source` where it answers one frame — or one appears that `lib/src/lib/webview-message-listeners.test.ts` does not classify; it names each listener's foreign-sender test.
- **FAIL IF** `S303` a message from a framed page moves DOM focus into a frame, or changes selection or mode while its frame does not hold focus, since a page can post one with no click and whatever holds focus reads what is typed: the `pointerdown` branch in `lib/src/components/wall/IframePanel.tsx` must test `document.activeElement` first. Pinned by `lib/src/components/Wall.test.tsx` and `lib/src/components/wall/IframePanel.test.tsx`.
- **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`.

Expand Down
4 changes: 3 additions & 1 deletion docs/specs/security-local.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ Why deceptive links are gated twice. The modal omits its Open action and focuses

## Browser panes

Why the origin check is not an authenticity check. The iframe proxy serves the untrusted upstream on the same origin it grants the shim, so `e.origin` cannot tell a message the shim sent from one the page sent; what the check buys is that no *other* frame can send them at all. The shim's actions are bounded downstream — exiting passthrough, selecting a pane, an `http:`/`https:` URL behind an open prompt, a frame-URL reading that may lie, and read-only theme variables. Theme delivery additionally checks the actual iframe window; requesting it grants no host command. `use-wall-keyboard`'s leader channel accepts any live grant rather than one panel's, so a page in one browser pane can exit passthrough while another is focused. The nested-frame relay preserves this boundary: it accepts only the same proxy origin and reconstructs one of the three pane-level shapes, so document-level locations, unrelated application messages, and every foreign origin stop at the child frame.
Why the origin check is not an authenticity check. The iframe proxy serves the untrusted upstream on the same origin it grants the shim, so `e.origin` cannot tell a message the shim sent from one the page sent; what the check buys is that no *other* frame can send them at all. The shim's actions are bounded downstream — exiting passthrough, selecting the pane whose frame already holds focus, an `http:`/`https:` URL behind an open prompt, a frame-URL reading that may lie, and read-only theme variables. Theme delivery additionally checks the actual iframe window; requesting it grants no host command. `use-wall-keyboard`'s leader channel accepts any live grant rather than one panel's, so a page in one browser pane can exit passthrough while another is focused. The nested-frame relay preserves this boundary: it accepts only the same proxy origin and reconstructs one of the three pane-level shapes, so document-level locations, unrelated application messages, and every foreign origin stop at the child frame.

Why "the standalone adapters" and not "the standalone webview". The Wall's two proxy-origin `message` listeners (`use-wall-keyboard.ts`, `IframePanel.tsx`) are bundled into the standalone webview as well as the VS Code one, so a framed page's `parent.postMessage` does reach that window. What has no forgeable inbox is the adapters' host channel, which arrives over Tauri IPC. `docs/specs/vscode.md` -> "Webview message authentication" scopes it the same way.

Why a page's press report counts only from a focused frame, and what that leaves. Focus is what the bound turns on: the frame holding it reads every key. Measured 2026-11 in Chromium 156 (the Wall included) and a macOS 27 WKWebView, a frame can still focus itself: Chromium at any time, WebKit once the frame has had a click. `inert` and the `focus-without-user-activation` policy (allow attribute and header) did not stop it in Chromium, so nothing a parent sets closes it. WebView2 and WebKitGTK were not run.

## The dor control socket

Why the socket's limit is stated rather than assumed. A `0700` directory and a `0600` file stop another local *account*; neither stops a process already running under the user's own uid, which can read the socket path out of its own environment. The same limit is already stated for the Burrow ACL store in `docs/specs/security-remote.md`, and stating it here keeps an agent holding `dor` from being read as a lesser principal than the person at the keyboard.
Expand Down
Loading
Loading