Repository navigation
fix(web): stop an MCP App navigating its sandbox frame elsewhere - #2875
Conversation
A frame sandboxed with allow-scripts may always navigate itself, and the sandbox flags follow it, so the page it lands on keeps the opaque "null" origin and the same contentWindow. That page passed every identity check the sandbox proxy's relay makes, received host messages relayed with "*", could post requests back to the host, and ran under none of the app's CSP. The proxy's own comment claimed the opposite. The proxy now installs a frame-src / child-src policy on its own document before loading the inner frame, built per render from the approved frameDomains (or the dedicated app origin), so a navigation to an undeclared origin is blocked before any request leaves the browser. The host passes the approved grant in the resource-ready message's spec `csp` slot for this. As defense in depth, a second load on the srcdoc frame closes the relay in both directions, removes the frame and reports an error to the host through the app logging channel. Adds smoke:web:sandbox, an engine smoke that drives the real proxy from the prod server with a stand-in host and asserts both controls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
Firefox blocks a frame-src-refused navigation without committing an error page, so no second load ever fires there and the host was never told. The proxy now also listens for the policy violation its own frame policy raises, which both Chromium and Firefox report, and closes the frame on whichever signal arrives first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟡 Changes recommended
An approved-domain navigation can retain the relay indefinitely when it produces the first load event, and the smoke test does not verify that declared-domain navigation actually succeeds.
2 open issues
What changed in this PR
This PR addresses #2872 by restricting MCP App sandbox navigation in the web client and closing message relays when navigation is detected.
Changes:
- Adds per-render frame policies using approved CSP grants, plus frame removal and host error reporting on detected navigation.
- Adds a sandbox-navigation browser smoke to the Chromium and Firefox engine suites.
- Updates bridge tests, smoke registration, and quality-gate documentation.
| File | Description |
|---|---|
scripts/smoke-web-sandbox-navigation.mjs |
Adds normal, blocked-navigation, and declared-domain smoke cases. |
scripts/run-engine-smokes.test.mjs |
Updates assertions for sandbox smoke registration. |
scripts/run-engine-smokes.mjs |
Includes the sandbox smoke in engine runs. |
scripts/lib/headless-browser.mjs |
Updates comments to include the new smoke. |
package.json |
Adds the smoke:web:sandbox command. |
docs/quality-gate.md |
Documents sandbox coverage, engine runs, and ports. |
clients/web/static/sandbox_proxy.html |
Adds frame policies, navigation detection, and relay shutdown. |
clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts |
Passes approved CSP grants to the proxy. |
clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts |
Verifies that only approved frame domains reach the proxy. |
Review effort: Balanced
The proxy's load watch assumed the srcdoc fires the frame's first `load`. A navigation started while the srcdoc is still loading aborts it, so the destination's `load` is the first and was ignored, leaving the relay open to the replacement document. The host wrapper now runs a script ahead of the app's code that posts a departure message on `beforeunload` and `pagehide`; the proxy closes the relay and removes the frame on it. The sandbox smoke gains a case that navigates while a never-finishing nested frame holds the srcdoc's load, and the declared-domain cases now require a nested frame to the declared domain to be fetched, so a policy refusing every navigation can no longer pass them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
|
Copilot round 1 (review 5775535843): two findings, fixed in 3859e09 and answered in each thread.
|
There was a problem hiding this comment.
🟡 Changes recommended
A critical navigation-detection bypass allows document.open() to remove departure listeners during a pending load, leaving the proxy relay open after navigation to an approved frame domain.
1 open issue
2 resolved since last review
Review effort: Balanced
|
Copilot round 2 (review 5776227654): one finding, declined in its thread as outside #2872. A |
CodeQL flagged JSON.stringify output spliced into script source in the departure script and the sandbox smoke (js/bad-code-sanitization), and a case-sensitive script-tag regex in a test (js/bad-tag-filter). The values are host constants, but escape <, > and the line separators anyway so no value can close the element or end a statement, and match the tag case-insensitively. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🔵 Needs a closer look
This browser-enforced security-boundary change needs independent Chromium/Firefox validation of CSP inheritance and navigation ordering, plus security-maintainer confirmation of the accepted declared-domain relay residual, because browser execution and private advisory review were unavailable.
No open issues 🎉
1 resolved since last review
Review effort: Balanced
CodeQL's js/bad-tag-filter flags any script-tag regex. The wrapper writes these bytes itself, so slice between the known tags instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>


Closes #2872
Fix for GHSA-h9j8-vj8g-v3gg, developed and reviewed in the advisory's private fork (inspector-GHSA-h9j8-vj8g-v3gg#1) and landed here as an ordinary PR. The commits are the fork branch's, unchanged.
An MCP App rendered on the opaque-origin (
srcdoc) path could navigate its own sandbox frame to another origin. Nothing stopped it:The navigated page kept origin
"null"and the samecontentWindow, so the proxy kept treating it as the app:connect-src 'none'"*"Changes
clients/web/static/sandbox_proxy.html).frame-srcandchild-srcallow the approvedframeDomainson the srcdoc path, the dedicated app origin on the_meta.ui.domainpath, and'none'otherwise.<meta>CSP, because the app's CSP grant is only known after the proxy page loads.frame-ancestors-only, persandbox-controller.ts: a static header would break approvedframeDomains, since srcdoc inherits it.cspslot ofsandbox-resource-ready(createAppBridgeFactory.ts). The proxy reads onlyframeDomainsand re-screens it.load, or asecuritypolicyviolationfrom the proxy's own frame policy, closes the relay in both directions.notifications/message(levelerror) to the host.load."*"was safe because a srcdoc frame cannot navigate elsewhere.Tests
createAppBridgeFactory.test.ts: only approvedframeDomainsreach the proxy.New engine smoke
npm run smoke:web:sandbox(scripts/smoke-web-sandbox-navigation.mjs), against the real prod-served proxy. It checks three cases:tools/call, and the error is reportedIt fails on the old proxy (
the frame reached an undeclared origin: /attack?leak=x). It is inENGINE_SMOKES: Chromium in CI, Firefox in the gate.Manual harness, Chromium 156 and Firefox, before and after: the leak and the bridge takeover reproduce on
v2/mainand are blocked with this change. Nested approved frames and dedicated-origin apps still work.npm run local:gate: green, includingsmoke:web:sandboxunder both engines and Storybook 123/123.Known residual
Navigating to a domain the app declared in
frameDomainspasses the policy. A fixed script in the host wrapper runs before the app's code and tells the proxy when the frame starts to leave, onbeforeunloadandpagehide. The proxy then closes the relay and removes the frame. It does this even when the app navigates before its own document has finished loading, with a second-loadcheck as a backup. In practice the frame is gone before the destination's first request is sent. A relayedtools/callin that window is now unlikely, but no browser guarantee rules it out. That is still within the grant: a declared frame domain is already an egress channel, and the app could send that call itself. Closing it fully needs a MessagePort transport, which requires SDK and spec changes and is out of scope.Lines
v2 only. v1's proxy checks the inner frame's real origin, so a navigated page's messages are rejected there.
🤖 Generated with Claude Code