inspector/clients/web/static/sandbox_proxy.html
cliffhall bb3ba91b8a
fix(web): guard the app-origin port and the trusted-origin grant
Copilot review round 3 on #2100.

- MCP_APP_ORIGIN_PORT could equal CLIENT_PORT or MCP_SANDBOX_PORT. The
  app-origin listener starts BEFORE the web server binds, so that is not a
  race it loses — it wins the port and the Inspector dies with EADDRINUSE.
  `CLIENT_PORT=6278` was a valid config before this feature existed. It now
  resolves to a dynamic port with a warning: of the three listeners this is
  the only one that can move, and refusing to boot would cost the user the
  whole Inspector over a feature they may not be using.
- The proxy's same-origin refusal covered only its own origin. An app served
  from the INSPECTOR's origin is no safer: same-origin with the top document
  means direct access to window.top, its DOM and its storage, and the
  cross-origin frame in between buys nothing. Both trusted origins are now
  refused before allow-same-origin is granted.
- Replaced the `as unknown as number` on `server.address()` with a real
  narrowing of the string/null members of its return type.
- MCP_APP_ORIGIN_PORT added to the v1→v2 migration guide's env table, its
  Docker recipe, and a troubleshooting entry for the symptom this actually
  produces (the app renders, but its own backend rejects `Origin: null`).
- clients/web/README.md now states which failure is outside the fallback
  guarantee — a published document the browser cannot reach — and why there
  is no honest signal to fall back on. Docs and code no longer disagree.

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>
2026-08-24 08:45:40 -04:00

349 lines
17 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<!-- 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
own CSP by executing in the parent's realm. The proxy itself is
additionally served with a restrictive CSP header (see
server/sandbox-controller.ts) so any future regression that re-grants
same-origin still gains nothing. The default sandbox value
("allow-scripts allow-forms") may be narrowed per app via the
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
src/utils/sandbox-csp.ts). The proxy assigns that pre-wrapped document
to `srcdoc` verbatim — it never parses or injects into the untrusted
bytes — so the CSP `<meta>` is guaranteed to be the first `<head>`
child of the inner document. -->
<title>MCP-UI Proxy</title>
<style>
html,
body {
margin: 0;
height: 100vh;
width: 100vw;
/* Transparent background allows parent page to show through */
background-color: transparent;
}
body {
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
}
iframe {
background-color: transparent;
border: 0px none transparent;
padding: 0px;
overflow: hidden;
flex-grow: 1;
/* Inherit color scheme from parent for consistent transparency */
color-scheme: inherit;
}
</style>
</head>
<body>
<script>
function buildAllowAttribute(permissions) {
if (!permissions) return "";
const allowList = [];
if (permissions["camera"]) allowList.push("camera");
if (permissions["microphone"]) allowList.push("microphone");
if (permissions["geolocation"]) allowList.push("geolocation");
if (permissions["clipboardWrite"]) allowList.push("clipboard-write");
return allowList.join("; ");
}
const ALLOWED_REFERRER_PATTERN =
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
if (window.self === window.top) {
throw new Error("This file is only to be used in an iframe sandbox.");
}
if (!document.referrer) {
throw new Error("No referrer, cannot validate embedding site.");
}
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
throw new Error(
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
);
}
// Extract the expected host origin from the referrer for origin validation.
// 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 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.
//
// 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
// configuration is dangerously broken and untrusted content could escape.
try {
window.top?.alert(
"If you see this, the sandbox is not setup securely.",
);
throw "FAIL";
} catch (e) {
if (e === "FAIL") {
throw new Error("The sandbox is not setup securely.");
}
// Expected: SecurityError confirms proper sandboxing.
}
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
// content. Per the specification, the Host and the Sandbox MUST have different
// origins.
const inner = document.createElement("iframe");
inner.style = "width:100%; height:100%; border:none;";
inner.setAttribute("sandbox", "allow-scripts allow-forms");
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
// based on the permissions requested by the app
document.body.appendChild(inner);
const RESOURCE_READY_NOTIFICATION =
"ui/notifications/sandbox-resource-ready";
const PROXY_READY_NOTIFICATION = "ui/notifications/sandbox-proxy-ready";
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
// forwarding messages between:
//
// Host (parent window) ↔ Sandbox (outer frame) ↔ View (inner iframe)
//
// Reason: the parent window and inner iframe have different origins and can't
// communicate directly, so the outer iframe forwards messages in both
// directions to connect them.
//
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
// intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the view HTML content.
//
// Security: isolation comes from the iframe `sandbox` attribute and the
// origin allowlist above. The CSP for the inner document is built and
// wrapped by the host before the HTML reaches this proxy (see head
// comment); this script writes that pre-wrapped document verbatim and
// never parses or injects into the untrusted bytes.
window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
// Validate that messages from parent come from the expected host origin.
// This prevents malicious pages from sending messages to this sandbox.
if (event.origin !== EXPECTED_HOST_ORIGIN) {
console.error(
"[Sandbox] Rejecting message from unexpected origin:",
event.origin,
"expected:",
EXPECTED_HOST_ORIGIN,
);
return;
}
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
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 BOTH trusted origins — this
// proxy's own and the Inspector page above it. Granting
// allow-same-origin below is what makes the app's origin real, and
// an app sharing the proxy's origin would land in this realm
// outright; one sharing the HOST's origin is no better, since
// same-origin with the top document means direct access to
// `window.top`, its DOM and its storage — the cross-origin frame in
// between buys nothing once the inner document is same-origin with
// the thing it is trying to reach. Either is a host
// misconfiguration, not app input, so refuse rather than degrade
// quietly.
if (
srcUrl &&
(srcUrl.origin === window.location.origin ||
srcUrl.origin === EXPECTED_HOST_ORIGIN)
) {
console.error(
"[Sandbox] refusing a src on a trusted 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
// bypass its own CSP via window.parent. Sandbox tokens are
// ASCII case-insensitive, so compare lowercased.
const safeTokens = sandbox
.split(/\s+/)
.filter((t) => t && t.toLowerCase() !== "allow-same-origin");
// Fail-closed but noisy: a server-supplied sandbox that omits
// allow-scripts leaves the inner frame unable to run the app.
// That's safe, but silently bricks the widget — log it so a
// developer can see why their app never started.
if (!safeTokens.some((t) => t.toLowerCase() === "allow-scripts")) {
console.warn(
"[Sandbox] server-supplied sandbox has no allow-scripts; the app will not execute:",
safeTokens.join(" "),
);
}
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 (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.
//
// Drop any `srcdoc` a previous render left behind first: it takes
// precedence over `src`, so a frame that had rendered an opaque
// document would keep showing it and never load this URL. Not
// reachable today (each render remounts the iframe, which reloads
// this document), but the cost of being wrong about that later is
// an app silently rendering the wrong bytes.
inner.removeAttribute("srcdoc");
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
// allow-same-origin (document.open/write would require reaching
// into the inner contentDocument across the opaque-origin
// boundary). Known trade-off: a small set of libraries (e.g.
// CesiumJS Map) don't initialise correctly under srcdoc — that
// is accepted in exchange for the isolation guarantee.
//
// Restore the opaque-origin posture first. A frame that
// previously took the dedicated-origin path still carries
// `allow-same-origin`, and `innerOrigin` still names that origin;
// assigning `srcdoc` under those would put the untrusted document
// in THIS proxy's realm — the exact escape the double-iframe
// model exists to prevent. The sandbox attribute is read at
// navigation time and assigning `srcdoc` is the navigation, so
// stripping it here takes effect for the document being loaded.
const fallbackTokens = (inner.getAttribute("sandbox") || "")
.split(/\s+/)
.filter((t) => t && t.toLowerCase() !== "allow-same-origin");
inner.setAttribute("sandbox", fallbackTokens.join(" "));
innerOrigin = "null";
inner.srcdoc = html;
}
} else {
if (inner && inner.contentWindow) {
// 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) {
// `event.source === inner.contentWindow` is the unforgeable identity
// check — only the actual inner frame can satisfy it. The opaque
// 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 !== innerOrigin) {
console.error(
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
event.origin,
"expected:",
innerOrigin,
);
return;
}
// Relay messages from inner frame to parent window.
// Use specific origin instead of "*" to prevent message interception.
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
}
});
// Notify the Host that the Sandbox is ready to receive view HTML.
// Use specific origin instead of "*" to ensure only the expected host receives this.
window.parent.postMessage(
{
jsonrpc: "2.0",
method: PROXY_READY_NOTIFICATION,
params: {},
},
EXPECTED_HOST_ORIGIN,
);
</script>
</body>
</html>