From 5a477a23b46dec6637de78c2240e4ac65679203d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:42:01 -0800 Subject: [PATCH 1/8] Keep a framed page's press report from moving focus into its frame The press report a proxied frame posts now selects the pane in passthrough and acknowledges its Session without moving DOM focus: the page shares the shim's origin, so it can post the same message with no click. The shim takes focus from inside for a real press, which also covers a page that cancels the press. A frame out of sight, which no one can click, is not heard, so selecting it cannot hand it focus when its Workspace comes back. Co-Authored-By: Claude Opus 5.5 --- lib/src/components/Wall.test.tsx | 52 ++++++++++++++++++- lib/src/components/Wall.tsx | 18 +++++-- lib/src/components/wall/IframePanel.test.tsx | 19 +++++-- lib/src/components/wall/IframePanel.tsx | 17 +++--- lib/src/components/wall/wall-context.tsx | 5 ++ lib/src/components/wall/wall-test-utils.ts | 1 + lib/src/host/iframe-proxy-rewrite.test.ts | 18 +++++-- lib/src/host/iframe-proxy-rewrite.ts | 7 +-- .../stories/BrowserChromeHeader.stories.tsx | 1 + lib/src/stories/MouseHeaderIcon.stories.tsx | 1 + lib/src/stories/ShellCwd.stories.tsx | 1 + .../stories/TerminalPaneHeader.stories.tsx | 1 + 12 files changed, 119 insertions(+), 22 deletions(-) diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index e3d8730f5..f0fdfab02 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -8,7 +8,7 @@ import { previousWorkspaceSession, setWorkspaceTransferPending } from '../lib/wi * geometry — the Wall on Lath live acceptance list in * TESTING_AND_MODIFICATION_GUIDE.md (§5) is the live gate. */ -import { act } from 'react'; +import { act, type ReactElement } from 'react'; import { type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { dispatchDorControlRequest } from '../lib/platform/dor-control-dispatch'; @@ -3744,6 +3744,56 @@ describe('Wall on the Lath engine', () => { return response; } + describe('a press reported from inside a proxied frame', () => { + const PROXY = 'http://127.0.0.1:61234'; + const press = () => act(async () => { + window.dispatchEvent(new MessageEvent('message', { origin: PROXY, data: { __dormouse: 'pointerdown' } })); + }); + + async function frameBeside(wall: ReactElement) { + fake.createIframeProxyUrl = vi.fn(async () => ({ ok: true as const, url: `${PROXY}/app` })); + await act(async () => root.render(wall)); + await flush(); + const { id } = await dispatchIframe('http://localhost:5173/'); + await flushFrame(); + const frame = container.querySelector(`[data-lath-leaf="${id}"] iframe`)!; + return { id, frame, focus: vi.spyOn(frame, 'focus') }; + } + + it('selects the pane in passthrough without moving DOM focus into the frame', async () => { + const { frame, focus } = await frameBeside(); + expect(focusOf('surface:a')).toBe('true'); + await press(); + await flushFrame(); + expect(focus).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(frame); + // The terminal that had the keyboard no longer does: the pane took selection. + expect(focusOf('surface:a')).toBe('false'); + }); + + it('focuses the frame for a click the Wall itself sees', async () => { + const { id, focus } = await frameBeside(); + await act(async () => { + container.querySelector(`[data-lath-leaf="${id}"] [tabindex="-1"]`)! + .dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }); + await flushFrame(); + expect(focus).toHaveBeenCalled(); + }); + + it('hears no press from a frame in a hidden Workspace', async () => { + const { frame, focus } = await frameBeside(); + await act(async () => root.render()); + await flushFrame(); + await press(); + await act(async () => root.render()); + await flushFrame(); + expect(focus).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(frame); + expect(focusOf('surface:a')).toBe('true'); + }); + }); + it('dor split transfers focus to the new surface (passthrough)', async () => { await act(async () => { root.render(); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 0c1da8ee0..51544acfa 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -847,16 +847,21 @@ export function Wall({ confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); }, [closeSurface]); + /** Select the panel in passthrough, leaving DOM focus where it is. */ + const selectInPassthrough = useCallback((id: string) => { + selectPane(id); + modeRef.current = 'passthrough'; + setMode('passthrough'); + }, [selectPane]); + /** Enter terminal mode for the given panel. This only moves focus there: the * human gestures acknowledge at their own handlers, and spawns, reveals, and * embeds focusing themselves never do. */ const enterTerminalMode = useCallback((id: string) => { - selectPane(id); - modeRef.current = 'passthrough'; - setMode('passthrough'); + selectInPassthrough(id); // Defer focus so it happens after the mousedown/click event finishes. requestAnimationFrame(() => focusSession(id, true)); - }, [selectPane]); + }, [selectInPassthrough]); const enterTerminalModeRef = useRef(enterTerminalMode); enterTerminalModeRef.current = enterTerminalMode; @@ -2129,6 +2134,11 @@ export function Wall({ setConfirmKill(null); enterTerminalMode(id); }, + onFramePointerDown: (id: string) => { + setConfirmKill(null); + selectInPassthrough(id); + acknowledgeSession(id); + }, onFocusPane: (id: string) => { setConfirmKill(null); // Visible pane → jump straight in; minimized (a door) → reattach first. diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index d949bdcdd..c81836d9f 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -375,9 +375,9 @@ describe('IframePanel', () => { // escaped one, the webview itself. Only this panel's own proxy origin speaks // for its frame (docs/specs/security-local.md -> "Browser panes"). it('acts on no message from any origin but its own proxy', async () => { - const onClickPanel = vi.fn(); + const onFramePointerDown = vi.fn(); proxyPlatform({ ok: true, url: `${PROXY}/app`, upstream: 'http://example.test/app' }); - await renderPanel(stubActions({ onClickPanel }), paneProps('iframe-foreign')); + await renderPanel(stubActions({ onFramePointerDown }), paneProps('iframe-foreign')); const chrome = () => getAgentBrowserScreenController('iframe-foreign')?.chrome().url; const before = chrome(); const send = (origin: string, data: unknown) => act(async () => { @@ -390,12 +390,23 @@ describe('IframePanel', () => { await send(origin, { __dormouse: 'open-window', url: `${PROXY}/docs` }); await send(origin, { __dormouse: 'navigate', url: 'http://localhost:5173/elsewhere' }); } - expect(onClickPanel).not.toHaveBeenCalled(); + expect(onFramePointerDown).not.toHaveBeenCalled(); expect(chrome()).toBe(before); expect(container.textContent).not.toContain('Open in new pane'); await send(PROXY, { __dormouse: 'pointerdown' }); - expect(onClickPanel).toHaveBeenCalledWith('iframe-foreign'); + expect(onFramePointerDown).toHaveBeenCalledWith('iframe-foreign'); + }); + + // A frame nobody can see, nobody can click. + it('hears no press from a parked frame', async () => { + const onFramePointerDown = vi.fn(); + proxyPlatform(); + await renderPanel(stubActions({ onFramePointerDown }), { ...paneProps('iframe-parked'), parked: true }); + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { origin: PROXY, data: { __dormouse: 'pointerdown' } })); + }); + expect(onFramePointerDown).not.toHaveBeenCalled(); }); it('re-resolves the proxy on Back after an observed in-frame navigation', async () => { diff --git a/lib/src/components/wall/IframePanel.tsx b/lib/src/components/wall/IframePanel.tsx index 80db9e319..498a08511 100644 --- a/lib/src/components/wall/IframePanel.tsx +++ b/lib/src/components/wall/IframePanel.tsx @@ -8,6 +8,7 @@ import type { IframeProxyResult } from '../../lib/platform/types'; import { IFRAME_HTTP_ONLY } from '../../lib/platform/iframe-proxy-types'; import type { PaneProps } from './pane-props'; import { usePaneChrome } from './use-pane-chrome'; +import { useSurfaceVisibility } from './use-surface-visibility'; import { PaneWriteContext, WallActionsContext } from './wall-context'; import { openAgentBrowserScreenModal, @@ -104,7 +105,7 @@ function upstreamUrlFromFrameLocation(frameUrl: unknown, targetUrl: string, prox } } -export function IframePanel({ id, title, params, onReady }: PaneProps & { +export function IframePanel({ id, title, params, parked, onReady }: PaneProps & { /** A frame after a document loads. */ onReady?: () => void; }) { @@ -377,17 +378,19 @@ export function IframePanel({ id, title, params, onReady }: PaneProps & { // A cross-origin click reaches only the frame, so the Wall never sees the // mousedown — and on WebKit the iframe element's own `focus` event doesn't // fire for it either. The shim posts `pointerdown` from inside the frame; - // adopt it as entering the pane (select + passthrough), exactly like clicking - // any other pane. Only genuine clicks emit `pointerdown`, so command-mode - // arrow navigation never triggers it, and onClickPanel is idempotent for - // repeat clicks. + // adopt it as selecting the pane in passthrough. The page shares the shim's + // origin and can post this with no click, so it never moves DOM focus (the + // shim takes focus from inside for a real press), and a frame out of sight, + // which no one can click, is not heard: selecting it would hand it focus when + // its Workspace comes back. + const onScreen = useSurfaceVisibility(parked, id); useEffect(() => { if (!proxyOrigin) return; const onMessage = (e: MessageEvent) => { if (e.origin !== proxyOrigin) return; const data = e.data as { __dormouse?: unknown; url?: unknown; loaded?: unknown; from?: unknown } | null; if (data?.__dormouse === 'pointerdown') { - actions.onClickPanel(id); + if (onScreen) actions.onFramePointerDown(id); return; } if (data?.__dormouse === 'open-window' && typeof data.url === 'string') { @@ -422,7 +425,7 @@ export function IframePanel({ id, title, params, onReady }: PaneProps & { }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); - }, [id, proxyOrigin, actions, liveUrl, sourceUrl, observeFrameUrl, followRedirect]); + }, [id, proxyOrigin, onScreen, actions, liveUrl, sourceUrl, observeFrameUrl, followRedirect]); // Raw fallback frames have no injected shim, but focusing a cross-origin // iframe still blurs the parent window while the document itself remains diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index 3b59cefd6..1b4d57cae 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -60,6 +60,10 @@ export interface WallActions { /** DOM focus reached a pane with no gesture seen — a raw cross-origin frame: * enter passthrough on it, acknowledging nothing. */ onEnterPanel: (id: string) => void; + /** A framed page's shim reported a press in its frame. The page can send this + * itself, so it moves selection and mode and acknowledges the pane's own + * Session, but never DOM focus: the frame takes focus for a real press. */ + onFramePointerDown: (id: string) => void; /** Jump to/focus an arbitrary pane by id (visible or minimized) as a click on * it would: passthrough, reattaching a Door, acknowledging its Session * without input. Used by the browser header's dev-server chip to surface the @@ -101,6 +105,7 @@ export const WallActionsContext = createContext({ onZoom: () => {}, onClickPanel: () => {}, onEnterPanel: () => {}, + onFramePointerDown: () => {}, onFocusPane: () => {}, onStartRename: () => {}, onFinishRename: () => ({ accepted: true }), diff --git a/lib/src/components/wall/wall-test-utils.ts b/lib/src/components/wall/wall-test-utils.ts index ad42b972a..b7e8092d2 100644 --- a/lib/src/components/wall/wall-test-utils.ts +++ b/lib/src/components/wall/wall-test-utils.ts @@ -28,6 +28,7 @@ export function stubWallActions(overrides: Partial = {}): WallActio onZoom: vi.fn(), onClickPanel: vi.fn(), onEnterPanel: vi.fn(), + onFramePointerDown: vi.fn(), onFocusPane: vi.fn(), onStartRename: vi.fn(), onFinishRename: vi.fn(() => ({ accepted: true })), diff --git a/lib/src/host/iframe-proxy-rewrite.test.ts b/lib/src/host/iframe-proxy-rewrite.test.ts index 696ffc2aa..0eb675c01 100644 --- a/lib/src/host/iframe-proxy-rewrite.test.ts +++ b/lib/src/host/iframe-proxy-rewrite.test.ts @@ -32,7 +32,8 @@ function shimFrame(parentOrigin: string, deliver: (data: unknown) => void) { if (target === parentOrigin) deliver(JSON.parse(JSON.stringify(data))); }, }; - const window = { parent, open: undefined as unknown }; + const focus = { held: false, calls: 0 }; + const window = { parent, open: undefined as unknown, focus() { focus.held = true; focus.calls++; } }; const properties = new Map(); const classes = new Set(); const root = { @@ -43,7 +44,7 @@ function shimFrame(parentOrigin: string, deliver: (data: unknown) => void) { runInNewContext(IFRAME_SHIM, { window, location: { origin: PROXY, href: `${PROXY}/story` }, - document: { readyState: 'loading', documentElement: root, body: root }, + document: { readyState: 'loading', documentElement: root, body: root, hasFocus: () => focus.held }, CustomEvent: class { constructor(public type: string) {} }, dispatchEvent: (event: { type: string }) => dispatched.push(event.type), history: {}, @@ -52,7 +53,7 @@ function shimFrame(parentOrigin: string, deliver: (data: unknown) => void) { URL, }); return { - parent, properties, classes, root, dispatched, + parent, properties, classes, root, dispatched, focus, emit(type: string, event: Record) { for (const listener of listeners.get(type) ?? []) listener(event); }, @@ -191,6 +192,17 @@ describe('the shim addresses the grant and app, not the world', () => { ]); }); + // The app does not focus a frame for this message, and a page that cancels the + // press gets no focus from the browser, so the frame takes it itself. Focusing + // a frame that holds focus can blank it on WebKit. + it('takes focus from inside on a press, only when the frame lacks it', () => { + const frame = shimFrame(APP, () => {}); + frame.emit('pointerdown', {}); + expect(frame.focus.calls).toBe(1); + frame.emit('pointerdown', {}); + expect(frame.focus.calls).toBe(1); + }); + it('relays pane-level messages but not nested locations through same-origin frames', () => { const delivered: unknown[] = []; const outer = shimFrame(APP, (data) => delivered.push(data)); diff --git a/lib/src/host/iframe-proxy-rewrite.ts b/lib/src/host/iframe-proxy-rewrite.ts index f1b315a8b..964f80a70 100644 --- a/lib/src/host/iframe-proxy-rewrite.ts +++ b/lib/src/host/iframe-proxy-rewrite.ts @@ -121,8 +121,9 @@ export function frameAncestorsCsp(embedderOrigins: string[]): string { // frame focused. // - `pointerdown`: a click landed in the frame. A cross-origin click reaches // only the frame, so the Wall can't see it; this lets it select the pane / -// enter passthrough. It's genuine user input, so it can't loop with the -// parent's programmatic focus. +// enter passthrough. The app never focuses a frame for this message, which +// the page can forge, so the frame takes focus itself: a page that cancels +// the press would otherwise keep the keyboard out of its own frame. // - `location`: the proxied frame's current URL. The parent converts it back // to the upstream URL and uses it to keep iframe Back/Forward/Reload chrome // honest. @@ -190,7 +191,7 @@ export function iframeShim(embedderOrigin: string): string { // Bound by position: a minifier may rename the function itself. var dualTap=${dualTap},taps=(${newDualTapState})(); addEventListener('keydown',function(e){if(dualTap(taps,e))post('leader');},true); - addEventListener('pointerdown',function(){post('pointerdown');},true); + addEventListener('pointerdown',function(){if(!document.hasFocus())window.focus();post('pointerdown');},true); addEventListener('click',function(e){ var a=anchorHref(e); if(!a||a.hasAttribute('download'))return; diff --git a/lib/src/stories/BrowserChromeHeader.stories.tsx b/lib/src/stories/BrowserChromeHeader.stories.tsx index 6445a632e..96807fb09 100644 --- a/lib/src/stories/BrowserChromeHeader.stories.tsx +++ b/lib/src/stories/BrowserChromeHeader.stories.tsx @@ -45,6 +45,7 @@ const loggingActions: WallActions = { onZoom: () => console.log('[story] zoom'), onClickPanel: () => console.log('[story] click panel'), onEnterPanel: () => console.log('[story] enter panel'), + onFramePointerDown: () => console.log('[story] frame pointer down'), onFocusPane: (id) => console.log('[story] focus pane', id), onStartRename: () => {}, onFinishRename: () => ({ accepted: true }), diff --git a/lib/src/stories/MouseHeaderIcon.stories.tsx b/lib/src/stories/MouseHeaderIcon.stories.tsx index 767815584..d7d4cab5d 100644 --- a/lib/src/stories/MouseHeaderIcon.stories.tsx +++ b/lib/src/stories/MouseHeaderIcon.stories.tsx @@ -28,6 +28,7 @@ const noopActions: WallActions = { onZoom: () => {}, onClickPanel: () => {}, onEnterPanel: () => {}, + onFramePointerDown: () => {}, onFocusPane: () => {}, onStartRename: () => {}, onFinishRename: () => ({ accepted: true }), diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index c269976ad..e8144a8ea 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -52,6 +52,7 @@ const noopActions: WallActions = { onZoom: () => {}, onClickPanel: () => {}, onEnterPanel: () => {}, + onFramePointerDown: () => {}, onFocusPane: () => {}, onStartRename: () => {}, onFinishRename: () => ({ accepted: true }), diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index 4e9f9ce1f..aa2f77354 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -30,6 +30,7 @@ const noopActions: WallActions = { onZoom: () => {}, onClickPanel: () => {}, onEnterPanel: () => {}, + onFramePointerDown: () => {}, onFocusPane: () => {}, onStartRename: () => {}, onFinishRename: () => ({ accepted: true }), From 10d0c7149ef8aff51eae07a05823cb03f0940c4b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:47:27 -0800 Subject: [PATCH 2/8] Refuse every metadata address and IPv6 form that carries one in the iframe proxy The address guard now covers the fixed cloud metadata addresses outside 169.254/16 and reads IPv6 literals as bytes, so a metadata address carried through a transition form (mapped in any spelling, translated, NAT64, 6to4, Teredo, ISATAP) is refused however it is written. Co-Authored-By: Claude Opus 5.5 --- lib/src/host/iframe-proxy-rewrite.test.ts | 60 ++++++++++++++++++ lib/src/host/iframe-proxy-rewrite.ts | 43 +++++++++---- lib/src/host/iframe-proxy.test.ts | 11 +++- lib/src/lib/ip-literal.ts | 77 +++++++++++++++++++---- 4 files changed, 167 insertions(+), 24 deletions(-) diff --git a/lib/src/host/iframe-proxy-rewrite.test.ts b/lib/src/host/iframe-proxy-rewrite.test.ts index 0eb675c01..582ffe89b 100644 --- a/lib/src/host/iframe-proxy-rewrite.test.ts +++ b/lib/src/host/iframe-proxy-rewrite.test.ts @@ -292,6 +292,66 @@ describe('isBlockedAddress', () => { expect(isBlockedAddress('[::ffff:169.254.169.254]')).toBe(true); // bracketed expect(isBlockedAddress('::169.254.169.254')).toBe(true); // compat form }); + it.each([ + 'fd00:ec2::254', // AWS + 'FD00:0EC2:0:0:0:0:0:0254', // the same, uppercase and in full + '[fd00:ec2::254%en0]', + 'fd20:ce::254', // Google Cloud + '100.100.100.200', // Alibaba Cloud + '192.0.0.192', // Oracle Cloud + '168.63.129.16', // Azure + '0xa8.63.129.16', + '::ffff:100.100.100.200', + ])('blocks the cloud metadata address %s', (address) => { + expect(isBlockedAddress(address)).toBe(true); + }); + // Each form hands the embedded address to a router or translator, so the + // connection ends at the IPv4 address however the IPv6 literal reads. + it.each([ + ['mapped, in full', '0:0:0:0:0:ffff:a9fe:a9fe'], + ['translated (SIIT)', '::ffff:0:a9fe:a9fe'], + ['NAT64', '64:ff9b::a9fe:a9fe'], + ['NAT64, dotted', '64:ff9b::169.254.169.254'], + ['NAT64, a fixed address', '64:ff9b::a83f:8110'], + ['local NAT64 /48', '64:ff9b:1:a9fe:a9:fe00::'], + ['local NAT64 /56', '64:ff9b:1:a9:fe:a9fe::'], + ['local NAT64 /64', '64:ff9b:1:0:a9:fea9:fe00:0'], + ['local NAT64 /96', '64:ff9b:1::a9fe:a9fe'], + ['a network-specific NAT64 /96', '2001:db8:64:1::a9fe:a9fe'], + ['Oracle Cloud’s IPv6 metadata', 'fd00:c1::a9fe:a9fe'], + ['6to4', '2002:a9fe:a9fe::'], + ['6to4, with a host', '2002:a9fe:a9fe:1::1'], + ['Teredo server', '2001:0:a9fe:a9fe::1'], + ['Teredo client', '2001:0:808:808::5601:5601'], + ['ISATAP', '2001:db8::5efe:a9fe:a9fe'], + ['ISATAP, global', '2001:db8::200:5efe:a9fe:a9fe'], + ])('blocks a metadata address embedded in IPv6: %s', (_form, address) => { + expect(isBlockedAddress(address)).toBe(true); + }); + it.each([ + 'fd00:ec2::253', // the neighbouring DNS resolver + 'fd00:ec2::1', + 'fd20:ce::253', + '100.100.100.199', + '192.0.0.193', + '168.63.129.17', + '64:ff9b::808:808', // a public address through NAT64 + '2002:808:808::', + '2001:0:808:808::f7f7:f7f7', + '2001:db8::5efe:808:808', + '2001:db8::1', + '2001:db8:1:2:3:4:a9fe:a9fe', // low bits that merely look like one + 'fe7f::1', + 'fec0::1', + '::', + '::1', + '1:2:3', + '1:2:3:4:5:6:7:8:9', + '1::2::3', + 'not:an:address', + ])('does not block %s', (address) => { + expect(isBlockedAddress(address)).toBe(false); + }); it('allows ordinary hosts', () => { expect(isBlockedAddress('127.0.0.1')).toBe(false); expect(isBlockedAddress('localhost')).toBe(false); diff --git a/lib/src/host/iframe-proxy-rewrite.ts b/lib/src/host/iframe-proxy-rewrite.ts index 964f80a70..adae3aa29 100644 --- a/lib/src/host/iframe-proxy-rewrite.ts +++ b/lib/src/host/iframe-proxy-rewrite.ts @@ -10,7 +10,7 @@ */ import { dualTap, newDualTapState } from '../lib/dual-tap'; -import { ipv4Value, isLoopbackHostname } from '../lib/ip-literal'; +import { ipv4Value, ipv4sCarriedByIPv6, ipv6Bytes, isLoopbackHostname } from '../lib/ip-literal'; /** Header bag shape both `http.IncomingHttpHeaders` and a plain map satisfy. */ export type ProxyHeaders = Record; @@ -376,6 +376,17 @@ function prologueEnd(html: string): number { // endpoint — as a numeric range so every equivalent encoding is caught. const LINK_LOCAL_V4_START = 0xa9fe0000; // 169.254.0.0 const LINK_LOCAL_V4_END = 0xa9feffff; // 169.254.255.255 +// Metadata endpoints outside that range, each one host in a block that also +// holds ordinary machines, so the address and not the block. +const CLOUD_METADATA_V4 = new Set([ + '100.100.100.200', // Alibaba Cloud + '192.0.0.192', // Oracle Cloud + '168.63.129.16', // Azure +].map((address) => ipv4Value(address))); +const CLOUD_METADATA_V6 = new Set([ + 'fd00:ec2::254', // AWS + 'fd20:ce::254', // Google Cloud +].map((address) => ipv6Bytes(address)!.join())); /** An address without brackets or a zone, lowercased. */ const bareAddress = (address: string) => address.replace(/^\[|\]$/g, '').replace(/%.*$/, '').toLowerCase(); @@ -399,19 +410,27 @@ export function isThisMachineAddress(address: string, interfaceAddresses: readon return interfaceAddresses.some((held) => addressKey(held) === key); } +function isBlockedIPv4(value: number): boolean { + return (value >= LINK_LOCAL_V4_START && value <= LINK_LOCAL_V4_END) || CLOUD_METADATA_V4.has(value); +} + +/** + * Whether a host names a link-local or cloud-metadata address, in any spelling: + * every IPv4 encoding (decimal/octal/hex, short forms), and every IPv6 literal + * that is one, or that carries one through a transition form. The WHATWG URL + * parser collapses the numeric IPv4 spellings before this guard runs, but a + * resolver's answer and a caller that did not parse have not been through it. + */ export function isBlockedAddress(hostname: string): boolean { + const v6 = ipv6Bytes(hostname); + if (!v6) { + const v4 = ipv4Value(hostname); + return v4 !== null && isBlockedIPv4(v4); + } // IPv6 link-local (fe80::/10). - if (/^\[?fe[89ab][0-9a-f]:/i.test(hostname)) return true; - // Resolve the host to its 32-bit IPv4 value across every equivalent encoding - // (decimal/octal/hex, short forms, IPv4-mapped IPv6) and range-check the - // link-local / cloud-metadata block. The genuine end-to-end hole a literal - // 169.254.* match leaves is the IPv6-embedded form (::ffff:169.254.169.254), - // which the WHATWG URL parser normalizes to a hex-group spelling the regex - // can't see; the numeric-IPv4 spellings are collapsed by that same parser - // before the guard runs, so canonicalizing them here is defense-in-depth - // against callers that don't pre-normalize rather than a live bypass fix. - const v4 = ipv4Value(hostname); - return v4 !== null && v4 >= LINK_LOCAL_V4_START && v4 <= LINK_LOCAL_V4_END; + if (v6[0] === 0xfe && (v6[1] & 0xc0) === 0x80) return true; + if (CLOUD_METADATA_V6.has(v6.join())) return true; + return ipv4sCarriedByIPv6(v6).some(isBlockedIPv4); } // --- Served error / diagnostic pages ---------------------------------------- diff --git a/lib/src/host/iframe-proxy.test.ts b/lib/src/host/iframe-proxy.test.ts index 4dba9675a..29f9b4ff8 100644 --- a/lib/src/host/iframe-proxy.test.ts +++ b/lib/src/host/iframe-proxy.test.ts @@ -156,6 +156,15 @@ describe('createIframeProxyUrl — admission', () => { if (!r.ok) expect(r.reason).toBe('scheme'); }); + it('refuses a metadata address the user allowed, by literal or by name', async () => { + const allowed = { ...NO_LOG, allowExternal: true, interfaceAddresses: () => [] }; + for (const literal of ['[fd00:ec2::254]', '[fd20:ce::254]', '[2002:a9fe:a9fe::]', '100.100.100.200']) { + expect(await createIframeProxyUrl(`http://${literal}/`, allowed)).toMatchObject({ ok: false, reason: 'scheme' }); + } + const resolve = async () => [{ address: 'fd00:ec2::254', family: 6 }]; + expect(await createIframeProxyUrl('http://docs.example/', { ...allowed, resolve })).toMatchObject({ ok: false, reason: 'scheme' }); + }); + it('preserves the target path + query in the framed URL', async () => { const port = await upstream((_q, s) => { s.writeHead(200, { 'content-type': 'text/html' }); s.end('x'); }); const url = await frame(`http://127.0.0.1:${port}/app/page?q=1`); @@ -178,7 +187,7 @@ describe('createIframeProxyUrl — the upstream address is fixed when the grant }; it('refuses a name that resolves to a link-local / metadata address', async () => { - for (const blocked of ['169.254.169.254', '::ffff:169.254.169.254', 'fe80::1']) { + for (const blocked of ['169.254.169.254', '::ffff:169.254.169.254', 'fe80::1', 'fd00:ec2::254', '64:ff9b::a9fe:a9fe', '168.63.129.16']) { const { resolve } = resolving(['127.0.0.1', blocked]); const r = await createIframeProxyUrl('http://rebind.test:8080/', { ...NO_LOG, resolve }); expect(r).toMatchObject({ ok: false, reason: 'scheme' }); diff --git a/lib/src/lib/ip-literal.ts b/lib/src/lib/ip-literal.ts index c306c3692..e9f80a34c 100644 --- a/lib/src/lib/ip-literal.ts +++ b/lib/src/lib/ip-literal.ts @@ -39,19 +39,74 @@ function parseIPv4(host: string): number | null { return value >>> 0 === value ? value : null; } -// Extract the 32-bit IPv4 address embedded in an IPv4-mapped or IPv4-compatible -// IPv6 literal (::ffff:169.254.169.254, ::ffff:a9fe:a9fe, ::169.254.169.254), -// or null if this isn't such an address. +/** A literal's 16 bytes, in any IPv6 spelling, bracketed or zoned or not, or null. */ +export function ipv6Bytes(hostname: string): number[] | null { + let h = hostname.replace(/^\[|\]$/g, '').replace(/%.*$/, '').toLowerCase(); + if (!h.includes(':')) return null; + const tail = h.slice(h.lastIndexOf(':') + 1); + if (tail.includes('.')) { + const v4 = parseIPv4(tail); + if (v4 === null) return null; + h = `${h.slice(0, -tail.length)}${(v4 >>> 16).toString(16)}:${(v4 & 0xffff).toString(16)}`; + } + const sides = h.split('::'); + if (sides.length > 2) return null; + const [head, rest] = sides.map((side) => (side ? side.split(':') : [])); + const missing = 8 - head.length - (rest?.length ?? 0); + if (rest ? missing < 1 : missing !== 0) return null; + const groups = [...head, ...Array(missing).fill('0'), ...(rest ?? [])]; + if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return null; + return groups.flatMap((group) => { + const value = parseInt(group, 16); + return [value >> 8, value & 0xff]; + }); +} + +const u32 = (b: readonly number[], at: number): number => + ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0; + +const allZero = (b: readonly number[], from: number, to: number): boolean => + b.slice(from, to).every((byte) => byte === 0); + +// The 32-bit IPv4 address an IPv4-mapped or IPv4-compatible IPv6 literal names +// (::ffff:169.254.169.254, ::ffff:a9fe:a9fe, ::169.254.169.254), or null if this +// isn't such an address. `::` and `::1` are themselves, not IPv4 compatible. function embeddedIPv4(h: string): number | null { - const m = h.match(/^::(?:ffff:)?(.+)$/); - if (!m) return null; - const tail = m[1]; - if (tail.includes('.')) return parseIPv4(tail.slice(tail.lastIndexOf(':') + 1)); - const groups = tail.split(':'); - if (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) { - return ((parseInt(groups[0], 16) << 16) >>> 0) + parseInt(groups[1], 16); + const b = ipv6Bytes(h); + if (!b || !allZero(b, 0, 10)) return null; + const v4 = u32(b, 12); + if (b[10] === 0xff && b[11] === 0xff) return v4; + return b[10] === 0 && b[11] === 0 && v4 > 0xffff ? v4 : null; +} + +/** + * Every IPv4 address an IPv6 literal's bytes hand to a router or translator, by + * the transition forms that carry one. For a guard: a literal may match a form + * only by its shape, so these name where it could end up, not where it does. + */ +export function ipv4sCarriedByIPv6(b: readonly number[]): number[] { + const carried: number[] = []; + const [g0, g1] = [(b[0] << 8) | b[1], (b[2] << 8) | b[3]]; + // The low 32 bits under any /96 ending in zeros: IPv4-compatible and every NAT64 + // prefix of that shape (RFC 6052), 64:ff9b::/96 among them; then mapped and + // translated, which spend those zeros on ffff. + if (allZero(b, 8, 12)) carried.push(u32(b, 12)); + if (allZero(b, 0, 10) && b[10] === 0xff && b[11] === 0xff) carried.push(u32(b, 12)); + if (allZero(b, 0, 8) && b[8] === 0xff && b[9] === 0xff && allZero(b, 10, 12)) carried.push(u32(b, 12)); + // Local-use NAT64 64:ff9b:1::/48 at RFC 6052's shorter lengths, which skip byte 8. + if (g0 === 0x64 && g1 === 0xff9b && b[4] === 0 && b[5] === 1) { + for (const start of [6, 7, 9]) { + const bytes = b.filter((_, i) => i >= start && i !== 8).slice(0, 4); + carried.push(u32(bytes, 0)); + } } - return null; + // 6to4, 2002::/16. + if (g0 === 0x2002) carried.push(u32(b, 2)); + // Teredo, 2001::/32: the server's address, then the client's with its bits flipped. + if (g0 === 0x2001 && g1 === 0) carried.push(u32(b, 4), ~u32(b, 12) >>> 0); + // ISATAP's interface identifier on any prefix. + if ((b[8] & ~0x02) === 0 && b[9] === 0 && b[10] === 0x5e && b[11] === 0xfe) carried.push(u32(b, 12)); + return carried; } /** The 32-bit IPv4 address a hostname spells — in any inet_aton encoding, or From 5cd7b05a42e0dd1eaea2b04558928ebf9e799c6e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:47:27 -0800 Subject: [PATCH 3/8] Keep service workers off local iframe proxy grants A worker registered on a grant's origin outlives the grant and answers the next grant on that port ahead of the proxy and its Clear-Site-Data. The proxy now answers a service worker script fetch with 403 on every grant but a tunneled one, whose page is not at that port. Co-Authored-By: Claude Opus 5.5 --- lib/src/host/iframe-proxy.test.ts | 29 +++++++++++++++++++++++++++++ lib/src/host/iframe-proxy.ts | 13 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/src/host/iframe-proxy.test.ts b/lib/src/host/iframe-proxy.test.ts index 29f9b4ff8..1e93dd804 100644 --- a/lib/src/host/iframe-proxy.test.ts +++ b/lib/src/host/iframe-proxy.test.ts @@ -1007,6 +1007,35 @@ describe('iframe proxy leases', () => { expect(await isListening(portOf(urls[0]))).toBe(false); }, 30_000); + it('refuses a service worker script on a local grant, and sends it nothing upstream', async () => { + const seen: string[] = []; + const port = await upstream((q, s) => { seen.push(q.url!); s.writeHead(200, { 'content-type': 'text/javascript' }); s.end('//'); }); + const url = await frame(`http://127.0.0.1:${port}/`, leased(lease())); + for (const headers of [{ 'Service-Worker': 'script' }, { 'Sec-Fetch-Dest': 'serviceworker' }, { 'Service-Worker': 'script', 'Sec-Fetch-Dest': 'serviceworker' }]) { + expect((await request(`${url}sw.js`, { headers })).status).toBe(403); + } + expect(seen).toEqual([]); + // Any other script is the page's own. + expect((await request(`${url}app.js`, { headers: { 'Sec-Fetch-Dest': 'script' } })).status).toBe(200); + expect((await request(`${url}worker.js`, { headers: { 'Sec-Fetch-Dest': 'worker' } })).status).toBe(200); + expect(seen).toEqual(['/app.js', '/worker.js']); + }); + + it('refuses a service worker script on an allowed external grant too', async () => { + const port = await upstream((_q, s) => { s.writeHead(200, { 'content-type': 'text/javascript' }); s.end('//'); }); + const url = await frame(`http://docs.example:${port}/`, { + ...NO_LOG, allowExternal: true, interfaceAddresses: () => [], + resolve: async () => [{ address: '127.0.0.1', family: 4 }], + }); + expect((await request(`${url}sw.js`, { headers: { 'Service-Worker': 'script' } })).status).toBe(403); + }); + + it('passes a service worker script through a tunneled grant, whose page is not at this port', async () => { + const port = await upstream((_q, s) => { s.writeHead(200, { 'content-type': 'text/javascript' }); s.end('//'); }); + const url = await frame(`http://127.0.0.1:${port}/`, { ...leased(lease()), tunneled: true }); + expect((await request(`${url}sw.js`, { headers: { 'Service-Worker': 'script' } })).status).toBe(200); + }); + it('clears site data on a freshly minted grant’s first response only', async () => { const port = await upstream((_q, s) => { s.writeHead(200, { 'content-type': 'text/html' }); s.end(''); }); const l = lease(); diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts index 4861ff047..d0defcde2 100644 --- a/lib/src/host/iframe-proxy.ts +++ b/lib/src/host/iframe-proxy.ts @@ -490,6 +490,11 @@ function upstreamRequestHeaders(grant: Grant, req: http.IncomingMessage): http.O return headers; } +/** The browser names a worker's script fetch, its first and every update, itself. */ +function isServiceWorkerScript(req: http.IncomingMessage): boolean { + return req.headers['service-worker'] === 'script' || req.headers['sec-fetch-dest'] === 'serviceworker'; +} + function handleRequest(grant: Grant, req: http.IncomingMessage, res: http.ServerResponse): void { if (!isLoopbackHost(req.headers.host, grant.port)) { // DNS rebinding: a hostile domain re-pointed at 127.0.0.1 reaches this @@ -505,6 +510,14 @@ function handleRequest(grant: Grant, req: http.IncomingMessage, res: http.Server // must not do is let a page that says `Origin: https://evil.example` hold a // closed pane's grant, and its upstream binding, open indefinitely. if (!isForeignOrigin(req.headers.origin, grant.port)) grant.lastUsed = Date.now(); + // A worker outlives its grant in the browser's storage for this origin, and + // would answer whichever later grant lands on this port, ahead of the proxy + // and its Clear-Site-Data. A tunneled page is not at this port. + if (!grant.tunneled && isServiceWorkerScript(req)) { + res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); + res.end('A preview runs without service workers.'); + return; + } const path = req.url ?? '/'; const headers = upstreamRequestHeaders(grant, req); From 471c8b028c84c41101565a54a02f9771d03d8225 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:47:27 -0800 Subject: [PATCH 4/8] Document the iframe press bound, worker refusal, and metadata forms Co-Authored-By: Claude Opus 5.5 --- docs/specs/dor-browser.md | 9 ++++++--- docs/specs/dor-browser.rationale.md | 8 +++++++- docs/specs/security-local.md | 3 ++- docs/specs/security-local.rationale.md | 4 +++- docs/specs/security.md | 1 + scripts/security-rule-ids.json | 2 +- scripts/spec-word-budgets.json | 6 +++--- 7 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index b4c226235..0a111c6a6 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 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 carries one through a transition form (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): @@ -274,13 +274,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.** @@ -322,7 +325,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` selects its pane in passthrough and acknowledges that pane's Session; the shim focuses its own frame for the press, since a browser gives a page that cancels one 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..aa67825f4 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. An IPv6 literal can reach any of them through a router or translator, so the check reads every transition form rather than a list of spellings: mapped, compatible, translated, NAT64, 6to4, Teredo, ISATAP. A deployment can choose its own NAT64 prefix, so any /96 ending in zeros counts; the cost is refusing a host whose static address happens to read that way. + +**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 `