fix(security): validate URL scheme before shell.openExternal (MCP-app sandbox escape)#3416
Merged
tatoalo merged 3 commits intoJul 15, 2026
Merged
Conversation
Contributor
|
hey @zachdotai, thanks a lot for this! Before merging, could you perhaps have a look at tightening the internal-navigation check? We can compare parsed URLs against the exact renderer origin/path, we could also maybe catch and log some |
Contributor
Author
this is now done! |
tatoalo
approved these changes
Jul 14, 2026
Contributor
|
@zachdotai we have commit signing protection enforced in the repo, approved otherwise! 🙌🏻 |
… sandbox escape) Untrusted MCP-app HTML could reach shell.openExternal with an arbitrary URL scheme, bypassing the http/https/mailto allowlist enforced elsewhere. A malicious MCP server could launch OS-registered scheme handlers from inside the sandbox (smb:/file: for NTLM theft, ms-msdt:-class handlers, app deep-links). - Extract the window-open / will-navigate handlers into external-links.ts and validate every URL with isSafeExternalUrl before shell.openExternal; log and drop anything outside http/https/mailto. - Remove allow-popups / allow-popups-to-escape-sandbox from the MCP-apps outer iframe, closing the proxy-realm window.open path that reached the sink.
auto-merge was automatically disabled
July 15, 2026 03:07
Head branch was pushed to by a user without write access
1a54fb0 to
b6a9f99
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Untrusted MCP-app HTML could reach
shell.openExternalwith an arbitrary URL scheme, bypassing the http/https/mailto allowlist we enforce everywhere else (isSafeExternalUrl,McpAppsService.openLink). This let a malicious/compromised MCP server hand the OS any scheme it has a handler for, from inside the "sandbox":smb://(NTLM hash theft on Windows),ms-msdt:-class handlers, or a registered app deep-link such aszoommtg://…— which macOS silently hands to the Zoom client, auto-joining an attacker-chosen meeting with zero further clicks.Root cause. The native window-open and navigation handlers in
apps/code/src/main/window.tsforwarded the URL straight to the OS with no validation:How the escape works. MCP apps render in a double-iframe sandbox, but untrusted app JS can still reach the unvalidated handler:
McpAppHostrenders the outer/proxy iframe frommcp-sandbox://proxywithsandbox="… allow-popups allow-popups-to-escape-sandbox".packages/shared/src/mcp-sandbox-proxy.ts) creates the inner iframe withallow-same-originand writes the untrusted app HTML into it viadocument.write(notsrcdoc, to preserve the origin for WebGL/canvas). Because ofallow-same-origin+document.write, the inner frame shares the proxy's origin.allow-popups, sowindow.open()from the app's own realm is blocked. But since the app is same-origin with its parent, it injects a<script>intowindow.parent.document. That script runs in the proxy realm, whose sandbox does carryallow-popups, sowindow.open(<any scheme>)there is permitted.window.openfiressetWindowOpenHandlerin the main process, which calledshell.openExternal(url)unconditionally → the OS dispatches the scheme handler.The PoC does this andalso fires once on render:
Changes
apps/code/src/main/external-links.tsand validate every URL withisSafeExternalUrl(packages/shared/src/url.ts— http/https/mailto only) in bothsetWindowOpenHandlerandwill-navigatebefore callingshell.openExternal. Anything else is logged (with the rejected scheme) and dropped.allow-popupsandallow-popups-to-escape-sandboxfrom the MCP-apps outer iframe (packages/ui/src/features/mcp-apps/components/McpAppHost.tsx). The inner iframe never hadallow-popups, so legitimate apps could not open popups anyway; apps open links through theui/open-linkbridge, whichMcpAppsService.openLinkalready scheme-validates.will-navigatenow parses the target URL and compares it against the exact renderer origin (dev server) or the packaged renderer directory (file://), instead of astartsWithprefix. This stops lookalikes such ashttp://localhost:5173@evil.example/orhttp://localhost:51730/from being treated as internal and skipping the scheme check.shell.openExternalrejections (review): it rejects when the OS has no handler for a scheme, so the promise is now caught and logged rather than left to surface as an unhandled rejection.biome.jsonc: addedexternal-links.tsto the allowlist of host files permitted to importelectron.How did you test this?
apps/code/src/main/external-links.test.ts, 22 cases): both handlers openhttps://…/http://…/mailto:…and do not opensmb://,ms-msdt://,custom-scheme://,javascript:, or a non-URL; in-app navigation is allowed only for the exact dev origin or a file under the packaged renderer directory, while lookalike origins (…@evil.example, longer port, scheme swap) and out-of-directoryfile://paths are punted to the external-link path; anopenExternalrejection is swallowed rather than left unhandled.pnpm --filter @posthog/code typecheck,pnpm --filter @posthog/ui typecheck, the fullapps/codevitest suite (308 passing), Biome on the touched files, andnode scripts/check-host-boundaries.mjs(no new violations) — all pass.openInProxyRealmsnippet. The blocked state is silent, so the PoC carries a small on-page diagnostic panel that confirms each step (inner frame is same-origin with the proxy, the injected script executes in the proxy realm, andwindow.open's return value). Before the fix, a registered scheme likezoommtg://reached the sink (Zoom launched / an unregistered scheme showed a macOS "no application set to open the URL" dialog). After the fix,window.openreturnsnull(popups removed) and the main-process log showsBlocked externally-opened URL with disallowed scheme.Automatic notifications