feat(web): configurable public addresses for the MCP Apps listeners (#1862)

Add MCP_SANDBOX_FULL_ADDRESS and MCP_APP_ORIGIN_FULL_ADDRESS so a reverse
proxy or ingress deployment can advertise the sandbox proxy URL and the
_meta.ui.domain app origin the browser actually reaches, instead of the
bind-derived http://localhost:<port> ones. The process still listens on
MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT.

Both values are validated in public-address.ts and refused (warned, bind-
derived address kept) when malformed or when their origin collapses onto
the Inspector UI's (or, for the app origin, the sandbox's) — the MCP Apps
spec requires host != sandbox origin, and the single-hostname proxy layout
is exactly the one that breaks it. Plain http under an https UI is warned.

The sandbox proxy page's referrer check was a hardcoded
http://localhost|127.0.0.1 pattern no https embedder could match. It now
compares the referrer's parsed origin against an exact list injected by the
controller, derived from the same filtered allow-list as its frame-ancestors
header, with the old loopback check kept only as the no-list fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 00:10:34 -04:00
commit 12544d251c

View file

@ -76,8 +76,19 @@
return allowList.join("; ");
}
const ALLOWED_REFERRER_PATTERN =
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
// The exact embedder origins this proxy accepts, injected by the sandbox
// controller when it serves this page (see `embedderOriginsLiteral` in
// server/sandbox-controller.ts). It is the same list the served
// `frame-ancestors` header admits, so the two checks agree by
// construction — an https or public-hostname Inspector page is accepted
// exactly when the backend's ALLOWED_ORIGINS names it (#1862).
//
// `null` means the controller had no valid list (a hand-constructed
// caller or a test); the header then falls back to the loopback family,
// and so does this check.
const ALLOWED_EMBEDDER_ORIGINS = /*INSPECTOR_EMBEDDER_ORIGINS*/ null;
const LOOPBACK_EMBEDDER_PATTERN =
/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
if (window.self === window.top) {
throw new Error("This file is only to be used in an iframe sandbox.");
@ -87,7 +98,22 @@
throw new Error("No referrer, cannot validate embedding site.");
}
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
// Compare the referrer's parsed ORIGIN, never the raw string: a pattern
// over the full referrer is a prefix/substring match an attacker page's
// path or query can be shaped to satisfy.
let referrerOrigin = null;
try {
referrerOrigin = new URL(document.referrer).origin;
} catch (e) {
referrerOrigin = null;
}
if (
!referrerOrigin ||
!(Array.isArray(ALLOWED_EMBEDDER_ORIGINS)
? ALLOWED_EMBEDDER_ORIGINS.includes(referrerOrigin)
: LOOPBACK_EMBEDDER_PATTERN.test(referrerOrigin))
) {
throw new Error(
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
);