feat(web): honor _meta.ui.domain with a dedicated app origin

An MCP App is rendered by handing its HTML to the sandbox proxy as
`srcdoc`, in a frame sandboxed without `allow-same-origin` — the #1565
isolation model. That gives the app document an opaque origin, so every
request it makes carries `Origin: null` and no CORS / OAuth-callback /
API-key allowlist can admit it. `_meta.ui.domain` is the spec field a
server uses to ask its host for a stable, dedicated origin, and it was
being dropped on the floor.

The field is explicitly host-dependent and the Inspector owns no domain
infrastructure, so it treats any non-empty value as a request rather than
an address, and answers with a real loopback origin on its own listener
(MCP_APP_ORIGIN_PORT, default 6276). One shared origin, path-keyed per
document: that delivers what the field is for without minting a port or
DNS name per app, and is therefore not a per-app isolation boundary.

The inner frame is granted `allow-same-origin` on this path only, which
is what makes the origin real. It is not a #1565 regression: the listener
is on its own port, so the app stays cross-origin to both the proxy and
the Inspector. The proxy refuses the grant if the URL's origin equals its
own, and the grant is never reachable from the server-supplied `sandbox`
string, which is still stripped. The per-app CSP is delivered as a real
response header there rather than only as a <meta>.

Every failure path falls back to the default srcdoc render with a console
warning naming the field, rather than blanking the app.

Also fixes a pre-existing `tsc -b` break on v2/main (useServers.test.tsx
passed `metadata: []` where `RequestMetadata` is expected), which has had
CI red since #2087 and blocks the pre-push gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 23:03:55 -04:00 • committed by GitHub
commit ec40cf5531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<!-- Isolation model: the inner iframe is sandboxed WITHOUT
<!-- Isolation model: by default the inner iframe is sandboxed WITHOUT
`allow-same-origin`, so the untrusted app runs under an opaque
("null") origin. That is the structural boundary — the app cannot
touch this proxy's DOM, window, or cookies, so it cannot bypass its
@ -15,6 +15,18 @@
resource-ready message's `sandbox` field; `allow-same-origin` is
always stripped from a server-supplied value.
Dedicated-origin exception (#2056): an app whose UI resource declares
`_meta.ui.domain` is instead served from the host's own app-origin
listener, handed to us as the resource-ready message's `src`. That
frame IS granted `allow-same-origin` — an opaque origin sends
`Origin: null`, which defeats the CORS / OAuth-callback / API-key
allowlists the field exists to support. The boundary is preserved by
the origin itself rather than by opacity: the listener is on its own
port, so the app is cross-origin to this proxy and to the host, and
the grant is refused outright if the URL's origin equals this page's.
The grant is host-driven — it is never reachable from the server's
own `sandbox` string, which is still stripped.
Network/resource egress from the inner document is constrained by a
Content-Security-Policy that the HOST builds and bakes into the
delivered HTML before it reaches this proxy (see
@ -85,13 +97,21 @@
// This is the origin we expect all parent messages to come from.
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
// The inner iframe is sandboxed without allow-same-origin, so its
// document has an opaque origin and every postMessage from it arrives
// The inner iframe is sandboxed without allow-same-origin by default, so
// its document has an opaque origin and every postMessage from it arrives
// with event.origin === "null" (the literal string). That value is
// checked below as a defense-in-depth assertion; the actual identity
// proof is `event.source === inner.contentWindow`, which a sibling
// frame cannot forge.
const INNER_ORIGIN = "null";
//
// The dedicated-origin path (#2056) is the one exception: when the host
// supplies a `src` on its own app-origin listener, the frame is granted
// allow-same-origin so the app has a REAL origin — which is the entire
// point, since an opaque origin sends `Origin: null` and defeats any
// CORS / OAuth-callback / API-key allowlist the app's backend applies.
// `innerOrigin` is then that URL's origin, and the assertion above still
// holds against it.
let innerOrigin = "null";
// Security self-test: verify iframe isolation is working correctly.
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
@ -158,7 +178,37 @@
}
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, permissions } = event.data.params;
const { html, sandbox, permissions, src } = event.data.params;
// The host asked for the dedicated-origin path: it published the
// wrapped document to its own app-origin listener and is handing
// us the URL. Only an absolute http(s) URL is honored — anything
// else (a relative path, a `javascript:` or `data:` URL) falls
// through to the srcdoc path below rather than being navigated to.
let srcUrl = null;
if (typeof src === "string") {
try {
const parsed = new URL(src);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
srcUrl = parsed;
} else {
console.warn("[Sandbox] ignoring non-http(s) src:", src);
}
} catch (e) {
console.warn("[Sandbox] ignoring unparseable src:", src);
}
}
// The app origin MUST differ from this proxy's own — otherwise
// granting allow-same-origin below would put the untrusted app in
// this realm, exactly the escape the double-iframe model exists to
// prevent. Refuse rather than degrade quietly: this is a host
// misconfiguration, not app input.
if (srcUrl && srcUrl.origin === window.location.origin) {
console.error(
"[Sandbox] refusing a src on the proxy's own origin:",
srcUrl.origin,
);
srcUrl = null;
}
if (typeof sandbox === "string") {
// Never grant allow-same-origin even if the server asks for it —
// that would let the untrusted app reach this proxy's DOM and
@ -179,13 +229,34 @@
}
inner.setAttribute("sandbox", safeTokens.join(" "));
}
if (srcUrl) {
// Re-grant what the strip above removes unconditionally. Safe
// here and only here: the document is served from a DIFFERENT
// origin than this proxy (asserted above), so same-origin policy
// still denies it this realm's DOM — it gains its own origin,
// not ours.
const tokens = (inner.getAttribute("sandbox") || "")
.split(/\s+/)
.filter(Boolean);
if (!tokens.some((t) => t.toLowerCase() === "allow-same-origin")) {
tokens.push("allow-same-origin");
}
inner.setAttribute("sandbox", tokens.join(" "));
}
// Set Permission Policy allow attribute if permissions are requested
const allowAttribute = buildAllowAttribute(permissions);
if (allowAttribute) {
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
inner.setAttribute("allow", allowAttribute);
}
if (typeof html === "string") {
if (srcUrl) {
// Navigate to the host's app-origin URL. The per-app CSP is
// delivered as a real response header there (stronger than the
// <meta> the srcdoc path relies on), so no wrapping applies here
// — this proxy still never parses the untrusted bytes.
innerOrigin = srcUrl.origin;
inner.src = srcUrl.href;
} else if (typeof html === "string") {
// The host has already wrapped the app's HTML in a fixed shell
// whose first <head> child is the CSP <meta>; assign it verbatim.
// srcdoc is the only delivery path that works without
@ -198,7 +269,16 @@
}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, "*");
// Target the inner frame's real origin on the dedicated-origin
// path, so a frame that navigated itself elsewhere stops
// receiving host messages. The opaque default has no targetable
// origin ("null" is not a valid targetOrigin), so it keeps "*" —
// safe there because the frame is `srcdoc` and cannot navigate
// to another origin's document without allow-top-navigation.
inner.contentWindow.postMessage(
event.data,
innerOrigin === "null" ? "*" : innerOrigin,
);
}
}
} else if (event.source === inner.contentWindow) {
@ -207,12 +287,12 @@
// sandbox means its origin serialises as the literal string "null";
// assert that as defense-in-depth so a future change that
// accidentally re-grants allow-same-origin is caught here.
if (event.origin !== INNER_ORIGIN) {
if (event.origin !== innerOrigin) {
console.error(
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
event.origin,
"expected opaque:",
INNER_ORIGIN,
"expected:",
innerOrigin,
);
return;
}