diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 015e125dc..bea73aea3 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -257,7 +257,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 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): @@ -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 | `` | 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 `` 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.** @@ -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). diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 423e6024a..fedf28127 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -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 ``, 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 `` nor `` got the shim before ``, 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 @@ -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:`. 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:`. 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 @@ -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 `