inspector/clients/web/static/sandbox_proxy.html
cliffhall fa441da85f
fix(web): harden the app-origin paths and correct three docs
Copilot review round 1 on #2100.

- POST /api/app-document: `null` and `[1,2]` are valid JSON, so the
  destructure that followed threw OUTSIDE the parse try/catch and turned a
  malformed body into a 500 instead of the documented 400.
- app-origin controller: the error handler cleared `server` but not
  `origin`, and `publish` gates on `origin` — so an error after a successful
  listen kept minting URLs on a dead port and the browser never took the
  srcdoc fallback. Clear both, and drop the now-unfetchable documents.
- sandbox proxy: drop a stale `srcdoc` before navigating to a dedicated
  origin (srcdoc wins over src), and restore the opaque-origin posture —
  strip `allow-same-origin`, reset `innerOrigin` — before assigning `srcdoc`
  on the fallback path. Not reachable today (each render remounts the
  iframe), but the cost of being wrong about that later is an isolation
  boundary.
- publishAppDocument tests: replace two `as unknown as Response` casts with
  real `Response`s.
- docs: `frame-ancestors` admits the proxy AND the Inspector page (every
  ancestor is checked); the shared helper falls back to loopback, not
  `'none'`; the showcase origin is `127.0.0.1`, the default bind address.
- tests: null/array body, post-listen error, and both factories receiving a
  working `publishAppDocument`.

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

339 lines
16 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 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
// 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>