Skip to content

Make iframe previews local-first, with an external-origin warning and allowlist - #1474

Merged
nedtwigg merged 10 commits into
mainfrom
issue-572-iframe-local-first
Nov 3, 2026
Merged

nedtwigg merged 10 commits into
mainfrom
issue-572-iframe-local-first

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Iframe panes now ask before previewing a website from another machine. Before this, the proxy fronted any http:// upstream, stripping a remote site's framing headers and CSP and serving it from the shared loopback hostname, with no warning. A frame load redirected off its upstream origin was forwarded as sent, and the webview's loopback-only frame-src refused it, leaving a blank pane.

What it does

  • Gate: both webview CSPs admit only loopback frames, so a remote page reaches a pane only through a minted grant. mintGrant refuses an upstream off this machine as external unless the request's allowExternal is exactly true, judged on the addresses the grant pins and never on the name. It uses the same allOnThisMachine check Show pages and Tools in Pocket over the encrypted session #1515 added. A leased external grant is not reused once the allowance is withdrawn.
  • Warning: the pane shows "External website preview" with Open in browser (the system browser), Allow this origin, and Cancel. Cancel goes back one page, or closes the pane if there is nothing to go back to.
  • Allowlist: exact origins, stored per install in the webview's localStorage, listed and removable in Settings → General. Allowing or removing one re-asks the host, so a removal closes an open preview.
  • Redirects: a pane's frame load redirected to another origin gets a Dormouse page that posts navigate to the app. The panel 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.
  • Audit: S299 in docs/specs/security-local.md -> "Browser panes", with a Guarantee row. The local-terminal stream of .github/audit/local.md already claims that heading, so the prompt is unchanged.

Allowing an origin permits a preview only: cookies stay stripped and https:// stays refused. Following an off-proxy link or form in place, and cookie support, stay under ## Future in docs/specs/dor-browser.md.

How this meets #1515

#1515 sends a phone's frames through this same proxy on tunneled grants. This PR gates local panes only:

  • A tunneled grant never reaches the allowlist. It is on this machine or refused, whatever allowExternal says, and that refusal runs first. That rule stays S294 in docs/specs/security-remote.md; S299 points at it.
  • A tunneled grant's off-origin redirect passes as sent, exactly as Show pages and Tools in Pocket over the encrypted session #1515 shipped it. PocketBrowserFrame handles no navigate, so the Dormouse page would be a dead end on a phone. What a phone should do instead is recorded in docs/specs/remote-api.md -> "1b. A frame redirected off its page", for a later PR.
  • The allowlist stays in the webview. docs/specs/remote-api.md -> "1a. Frames off this machine" already says a webview's allowlist cannot be a phone's consent, so a phone framing remote pages would need its own.
  • VS Code's createIframeProxyUrl takes an options object, so allowExternal and tunneled cannot be confused by position. vscode-ext/test/burrow.test.ts asks the real proxy through the glue and pins the refusal.

Testing

  • Targeted vitest passes for the proxy, frame host, IframePanel, Settings, the VS Code router and Burrow glue, and the standalone adapters.
  • The new story's play tests pass in storybook-chromium, and cargo check passed before the Show pages and Tools in Pocket over the encrypted session #1515 merge; the Rust command has not changed since.
  • tsc passes in lib, standalone and vscode-ext, as do the specs, md, public-docs, outbound and loopback lints.
  • Each fix was mutation-checked: the gate, lease reuse, exact-true, the tunneled-first refusal, the tunneled redirect pass-through, and the glue's tunneled.

Not verified: the warning, allowlist and redirect in a live VS Code or Tauri window, and anything on a real phone.

Fixes #572

🤖 Generated with Claude Code

nedtwigg and others added 3 commits October 30, 2026 13:30
… 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 <noreply@anthropic.com>
Exports ExternalPreviewPrompt so its layout and actions have a story: each
action reaches its own handler, a host with no system browser drops Open in
browser, and a long origin wraps in a narrow pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ned asked for the gate to be an audited rule. security-local.md -> Browser
panes gains a Guarantee row and FAIL IF S294 (claimed by the local domain's
local-terminal stream, which already holds that heading); dor-browser.md's
two bolded rules become the mechanism pointing at it.

S294 skips S292 and S293, which the unmerged issue-1166-revoke-access-log
takes; they sit in retired here only so the ledger lint passes on this
branch alone, and come out of retired when the two branches meet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 59a718d
Status: ✅  Deploy successful!
Preview URL: https://b294fcab.mouseterm.pages.dev
Branch Preview URL: https://issue-572-iframe-local-first.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev servers on this machine whose URL isn't spelled as loopback will now hit the "External website preview" warning. Details inline.

Comment thread lib/src/host/iframe-proxy-rewrite.ts Resolved
nedtwigg and others added 3 commits October 30, 2026 16:20
…back

Review on #1474: the gate called two kinds of upstream on this machine
external. `python -m http.server` and other servers bound to every interface
print http://0.0.0.0:<port>/, and listenerUrlsByPort names a server bound to
one LAN, bridge, or tailnet interface by that interface's address. Both
connect here, and both now opened behind the external-website warning.

isLoopbackAddress becomes isThisMachineAddress: loopback, the unspecified
0.0.0.0 and ::, or an address one of this host's interfaces holds, read from
os.networkInterfaces() at mint (injectable for tests), IPv4-mapped spellings
counting as their IPv4. A private address on another host stays external.
The spec's definition of local, S294, and the rationale name the new set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve scripts/spec-word-budgets.json by taking the larger budget per spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve scripts/security-rule-ids.json to next 295 with only 265 retired, since #1459 now uses S292 and S293 and this branch uses S294. Resolve scripts/spec-word-budgets.json by taking the larger budget per spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1487's story-clock fix for the access-log story, which is the one changed Argos snapshot every open PR showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1515 added tunneled phone grants to the same proxy, so the merge reconciles the two gates in mintGrant: one locality check, refusing a tunneled grant first, then an external one the user has not allowed.

- Pass allowExternal and tunneled to the VS Code host in an options object. Both were the fourth positional argument, so a plain merge let the webview's flag set tunneled.
- Keep main's isThisMachineAddress and drop this branch's copy.
- Skip the off-origin redirect page on a tunneled grant, whose Pocket frame cannot follow it.
- Renumber this branch's rule to S299, since main's S294 is the frame tunnel, and set next to 300.
- Ratchet docs/specs/dor-browser.md, since both sides added words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The merge from main (e8b837e) stopped handleRequest from serving the off-origin redirect page on a tunneled grant, but the spec and S299 still describe that page as covering every frame load with an embedder chain. Tunneled grants always have a chain, because createBurrowFrameHost refuses to open one without it. So the nightly audit will read the !grant.tunneled check as a violation of S299, and a reader of "Iframe External Origins" won't learn that Pocket frames get the Location unchanged. Suggestions inline. If the exemption is better owned by docs/specs/remote-api.md -> "Frames", point there instead.

Comment thread docs/specs/dor-browser.md Resolved
Comment thread docs/specs/security-local.md Resolved
…he phone

#1515 sends a phone's frames through the same proxy, on tunneled grants that
are refused off this machine whatever allowExternal says. The merge made the
code agree; this pins it and says so.

A tunneled grant now has a test that an off-origin redirect passes as sent:
PocketBrowserFrame handles no `navigate`, so the Dormouse page would be a dead
end there. burrow.test.ts pins the VS Code glue asking the real proxy for a
phone's frame as tunneled, through the argument shape that once collided with
allowExternal.

S299 and the Guarantee row name local panes and point at S294 for tunneled
grants; dor-browser.md defers its definition of local to remote-api.md's, which
also stops saying a local pane keeps every remote page. The phone's redirect is
recorded in remote-api.md's Future. pinAddresses' comment no longer claims every
private address is trusted, which stopped being true when the gate landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg
nedtwigg marked this pull request as ready for review November 2, 2026 23:54
Keep both sides of vscode-adapter.ts's imports: IframeProxyOptions from this branch and TERMINAL_CONTEXT_TIMEOUT_MS from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg
nedtwigg merged commit 79919cc into main Nov 3, 2026
20 checks passed
@nedtwigg
nedtwigg deleted the issue-572-iframe-local-first branch November 3, 2026 00:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make iframe previews local-first with an external-origin warning and allowlist

2 participants