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>
339 lines
16 KiB
HTML
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>
|