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
6 changed files with 737 additions and 16 deletions

View file

@ -162,6 +162,14 @@ export interface AppOriginControllerOptions {
* loopback, exactly as the sandbox proxy's own `frame-ancestors` does.
*/
embedderOrigins?: string[];
/**
* The origin to serve published documents under instead of the bind-derived
* one — the operator's `MCP_APP_ORIGIN_FULL_ADDRESS`, already validated by
* `public-address.ts` (#1862). Only adopted once the listener is up, so a
* failed bind still makes {@link AppOriginController.publish} return null
* and the renderer keeps its srcdoc fallback.
*/
publicOrigin?: string;
}
/** A document handed to {@link AppOriginController.publish}. */
@ -237,9 +245,17 @@ export function createAppOriginController(
// Same defaulting rationale as the sandbox controller: never the *name*
// `localhost`, which resolves to a single address family and would put this
// listener on a different family than the web server (#1951).
const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options;
const {
port,
host = DEFAULT_BIND_HOST,
embedderOrigins,
publicOrigin,
} = options;
let server: Server | null = null;
let origin: string | null = null;
// Tracked separately from `origin`: a public origin carries the public port
// (or none), not the one this listener bound.
let boundPort = 0;
// Insertion-ordered, so the first key is the oldest entry.
const documents = new Map<string, StoredDocument>();
@ -268,7 +284,7 @@ export function createAppOriginController(
return {
async start(): Promise<{ port: number; url: string }> {
if (server && origin) {
return { port: parseInt(new URL(origin).port, 10), url: origin };
return { port: boundPort, url: origin };
}
return new Promise((resolve) => {
let settled = false;
@ -364,7 +380,10 @@ export function createAppOriginController(
const urlHost = isAllInterfacesHost(canonicalHost)
? "localhost"
: canonicalHost;
origin = `http://${urlHost}:${actualPort}`;
// An operator-supplied public origin (#1862) replaces the derived one
// only now that the listener it routes to is actually up.
origin = publicOrigin ?? `http://${urlHost}:${actualPort}`;
boundPort = actualPort;
settle({ port: actualPort, url: origin });
});
});

View file

@ -0,0 +1,201 @@
/**
* Public addresses for the MCP Apps listeners (#1862): the URL the browser is
* told to load the sandbox proxy from, and the origin a `_meta.ui.domain` app
* document is served from, when either differs from the address the process
* binds.
*
* ## Why an override exists
*
* Both listeners advertise a URL built from their own bind — `http://localhost:
* 6275/sandbox` under a wildcard bind. That is right whenever the browser shares
* the process's view of the network (a desktop, a container published on the
* same port numbers, an SSH tunnel), and wrong behind a reverse proxy or an
* ingress, where the browser reaches the listener at a public hostname and
* frequently over HTTPS. The port knobs (`MCP_SANDBOX_PORT`,
* `MCP_APP_ORIGIN_PORT`) cannot express a scheme or a hostname, so:
*
* - `MCP_SANDBOX_FULL_ADDRESS` — the sandbox proxy URL, e.g.
* `https://inspector-sandbox.example.com/sandbox`. A bare origin gets
* `/sandbox` appended, since that is the only path the listener serves.
* - `MCP_APP_ORIGIN_FULL_ADDRESS` — the app-origin listener's origin, e.g.
* `https://inspector-apps.example.com`. An origin only: documents are served
* at `<origin>/app-document/<id>`, so a path would name nothing.
*
* Neither changes what the process listens on; the operator routes the public
* address to the listener.
*
* ## Why the values are validated rather than passed through
*
* The sandbox origin is an isolation boundary, not merely an address. The MCP
* Apps spec requires host ≠ sandbox origin, and today that holds structurally —
* the listener is on its own port of the same process, so it cannot be
* misconfigured. An operator-supplied address makes it configurable, and the
* most natural reverse-proxy layout (`https://inspector.example.com/sandbox`
* routed to `:6275`) is exactly the one that collapses it. The proxy page's own
* self-test and its #2056 `src` check both fail closed, but their symptom is a
* blank frame and a console throw; refusing here names the cause at boot.
*
* So a value is **refused** — warned and ignored, falling back to the
* bind-derived address, matching the warn-and-drop precedent `ALLOWED_ORIGINS`
* and `MCP_SANDBOX_PORT` set — when it is not an absolute `http(s)` URL, carries
* credentials, a query, a fragment, a wildcard, or a bracketed IPv6 literal (not
* a valid CSP host-source, and the sandbox origin is emitted into published
* documents' `frame-ancestors`), or when its origin equals an Inspector UI
* origin (or, for the app origin, the sandbox's). A plain-`http` address under
* an `https` UI is only **warned**: the browser blocks it as mixed content, but
* `ALLOWED_ORIGINS` can legitimately list both schemes at once, so it is not
* provably wrong.
*
* Not `DANGEROUSLY_`-prefixed: it widens no bind and disables no check, and the
* configurations that would be dangerous are rejected rather than renamed.
*/
export const SANDBOX_FULL_ADDRESS_ENV = "MCP_SANDBOX_FULL_ADDRESS";
export const APP_ORIGIN_FULL_ADDRESS_ENV = "MCP_APP_ORIGIN_FULL_ADDRESS";
/** The only path the sandbox listener serves (see `sandbox-controller.ts`). */
const SANDBOX_PATH = "/sandbox";
/** A parsed address, or the reason it was refused. */
type Parsed = { url: URL } | { reason: string };
/**
* Shape checks shared by both addresses. Returns the reason for the first
* failure, so the warning names one concrete thing to fix.
*/
function parsePublicUrl(raw: string): Parsed {
let url: URL;
try {
url = new URL(raw);
} catch {
return { reason: "it is not an absolute URL" };
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { reason: "it needs an http:// or https:// scheme" };
}
if (url.username || url.password) {
return { reason: "it must not carry credentials" };
}
// `new URL` accepts `*` in a hostname; it is never a reachable address.
if (url.hostname.includes("*")) {
return { reason: "it must not contain a wildcard" };
}
if (url.hostname.startsWith("[")) {
return {
reason:
"a bracketed IPv6 literal is not a valid CSP host-source — use a hostname or an IPv4 address",
};
}
if (url.search || url.hash) {
return { reason: "it must not carry a query string or fragment" };
}
return { url };
}
function warnIgnored(env: string, raw: string, reason: string): void {
console.warn(
`Ignoring ${env}="${raw}": ${reason}. Advertising the bind-derived address instead.`,
);
}
/**
* Warn when a plain-`http` address will be loaded by an `https` UI. Not a
* refusal — see the header comment.
*/
function warnMixedContent(
env: string,
url: URL,
allowedOrigins: readonly string[],
): void {
if (
url.protocol === "http:" &&
allowedOrigins.some((o) => o.startsWith("https:"))
) {
console.warn(
`${env} is plain http (${url.origin}) but ALLOWED_ORIGINS includes an https origin: ` +
`a browser on the https UI blocks it as mixed content and MCP Apps will not render there.`,
);
}
}
/**
* The public sandbox proxy URL from {@link SANDBOX_FULL_ADDRESS_ENV}, or
* `undefined` when it is unset, blank, or refused (warned).
*
* @param allowedOrigins The resolved Inspector UI origins — the sandbox must
* not share an origin with any of them.
*/
export function resolveSandboxPublicUrl(
raw: string | undefined,
allowedOrigins: readonly string[],
): string | undefined {
const value = raw?.trim();
if (!value) return undefined;
const parsed = parsePublicUrl(value);
if ("reason" in parsed) {
warnIgnored(SANDBOX_FULL_ADDRESS_ENV, value, parsed.reason);
return undefined;
}
const { url } = parsed;
// A bare origin names the listener, not the page; the page is `/sandbox`.
if (url.pathname === "/") url.pathname = SANDBOX_PATH;
if (allowedOrigins.includes(url.origin)) {
warnIgnored(
SANDBOX_FULL_ADDRESS_ENV,
value,
`its origin ${url.origin} is also an Inspector UI origin (ALLOWED_ORIGINS), and the MCP Apps ` +
`sandbox must be on a different origin from its host — route it to its own hostname or port`,
);
return undefined;
}
warnMixedContent(SANDBOX_FULL_ADDRESS_ENV, url, allowedOrigins);
return url.href;
}
/**
* The public app-origin from {@link APP_ORIGIN_FULL_ADDRESS_ENV}, or
* `undefined` when it is unset, blank, or refused (warned).
*
* @param allowedOrigins The resolved Inspector UI origins.
* @param sandboxUrl The advertised sandbox URL when it is known at config time
* (i.e. overridden). A bind-derived one is on its own port and cannot match.
*/
export function resolveAppOriginPublicOrigin(
raw: string | undefined,
allowedOrigins: readonly string[],
sandboxUrl: string | undefined,
): string | undefined {
const value = raw?.trim();
if (!value) return undefined;
const parsed = parsePublicUrl(value);
if ("reason" in parsed) {
warnIgnored(APP_ORIGIN_FULL_ADDRESS_ENV, value, parsed.reason);
return undefined;
}
const { url } = parsed;
if (url.pathname !== "/") {
warnIgnored(
APP_ORIGIN_FULL_ADDRESS_ENV,
value,
"it must be an origin only, with no path — app documents are served at <origin>/app-document/<id>",
);
return undefined;
}
// Same-origin with the host or the proxy is precisely what the dedicated
// origin must never be: the inner frame is granted `allow-same-origin` on
// this path, so sharing either origin hands the app that realm.
const trusted = sandboxUrl
? [...allowedOrigins, new URL(sandboxUrl).origin]
: allowedOrigins;
if (trusted.includes(url.origin)) {
warnIgnored(
APP_ORIGIN_FULL_ADDRESS_ENV,
value,
`its origin ${url.origin} is also the Inspector UI's or the sandbox's, and a dedicated app ` +
`origin must differ from both — route it to its own hostname or port`,
);
return undefined;
}
warnMixedContent(APP_ORIGIN_FULL_ADDRESS_ENV, url, allowedOrigins);
return url.origin;
}

View file

@ -29,6 +29,14 @@ export interface SandboxControllerOptions {
* list (only a hand-constructed caller or a test) falls back to loopback-only.
*/
allowedOrigins?: string[];
/**
* The URL to advertise instead of the bind-derived one — the operator's
* `MCP_SANDBOX_FULL_ADDRESS`, already validated by `public-address.ts`
* (#1862). It changes what {@link SandboxController.getUrl} returns, never
* what is bound; and it is only advertised once the listener is actually up,
* so a failed bind still reports no sandbox rather than a dead URL.
*/
publicUrl?: string;
}
// Loopback fallback. NB: no `http://[::1]:*` — a **bracketed IPv6 literal is not
@ -86,11 +94,59 @@ export function sandboxFrameAncestors(allowedOrigins?: string[]): string {
* derive it here rather than each rolling one.
*/
export function frameAncestorsDirective(origins?: string[]): string {
const valid = (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
const valid = validHostSources(origins);
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
return `frame-ancestors ${sources.join(" ")}`;
}
/** The entries of `origins` that are well-formed CSP host-sources. */
function validHostSources(origins?: string[]): string[] {
return (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
}
/**
* The marker in `static/sandbox_proxy.html` that {@link renderSandboxProxyHtml}
* replaces, together with the `null` it stands in front of. Matching the pair
* means an unrendered page is still valid JavaScript, and it falls back to the
* loopback check rather than to accepting anything.
*/
export const EMBEDDER_ORIGINS_PLACEHOLDER =
"/*INSPECTOR_EMBEDDER_ORIGINS*/ null";
/**
* The JavaScript literal the proxy page's referrer check reads as its exact
* embedder allow-list (#1862).
*
* It is derived from the SAME filtered list as {@link sandboxFrameAncestors},
* so the referrer check and the `frame-ancestors` header admit one set. Before
* this the page hardcoded `http://localhost|127.0.0.1`, which no `https://`
* referrer can match — so the proxy threw on every HTTPS deployment regardless
* of `ALLOWED_ORIGINS`. When nothing valid remains the literal is `null`, and
* the page falls back to that loopback check, mirroring the header's own
* loopback fallback.
*
* The value lands inside a `<script>`, so `<` is escaped: `CSP_HOST_SOURCE`
* does not exclude it, and a `</script>` inside a string literal would close
* the element regardless of the quotes around it.
*/
export function embedderOriginsLiteral(origins?: string[]): string {
const valid = validHostSources(origins);
if (valid.length === 0) return "null";
return JSON.stringify(valid).replace(/</g, "\\u003c");
}
/** Substitute the embedder allow-list into the proxy page's source. */
export function renderSandboxProxyHtml(
html: string,
origins?: string[],
): string {
// Function-form replacement: a string replacement would interpret `$&` and
// friends, and the literal is operator-derived.
return html.replace(EMBEDDER_ORIGINS_PLACEHOLDER, () =>
embedderOriginsLiteral(origins),
);
}
export interface SandboxController {
start(): Promise<{ port: number; url: string }>;
close(): Promise<void>;
@ -156,9 +212,12 @@ export function createSandboxController(
// `localhost` — a name resolves to one address family and would reintroduce
// the #1951 split (web on IPv4, sandbox on IPv6) for any future call site
// that omits `host`. Both call sites pass `config.sandboxHost` today.
const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options;
const { port, host = DEFAULT_BIND_HOST, allowedOrigins, publicUrl } = options;
let server: Server | null = null;
let sandboxUrl: string | null = null;
// Tracked separately from the URL: an advertised public URL carries the
// public port (or none at all), not the one this listener bound.
let boundPort = 0;
// Defense-in-depth for the proxy page itself. Only `frame-ancestors` is set
// here — fetch directives (`default-src`, `connect-src`, etc.) are
@ -175,7 +234,10 @@ export function createSandboxController(
let sandboxHtml: string;
try {
const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html");
sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8");
sandboxHtml = renderSandboxProxyHtml(
readFileSync(sandboxHtmlPath, "utf-8"),
allowedOrigins,
);
} catch (e) {
sandboxHtml =
"<!DOCTYPE html><html><body>Sandbox not loaded: " +
@ -186,8 +248,7 @@ export function createSandboxController(
return {
async start(): Promise<{ port: number; url: string }> {
if (server && sandboxUrl) {
const p = parseInt(new URL(sandboxUrl).port, 10);
return { port: p, url: sandboxUrl };
return { port: boundPort, url: sandboxUrl };
}
return new Promise((resolve) => {
// Guard so a `listen` error followed by a (theoretically possible)
@ -277,7 +338,10 @@ export function createSandboxController(
const urlHost = isAllInterfacesHost(canonicalHost)
? "localhost"
: canonicalHost;
sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`;
// An operator-supplied public URL (#1862) replaces the derived one
// only now that the listener it routes to is actually up.
sandboxUrl = publicUrl ?? `http://${urlHost}:${actualPort}/sandbox`;
boundPort = actualPort;
settle({ port: actualPort, url: sandboxUrl });
});
});

View file

@ -46,6 +46,7 @@ export async function startHonoServer(
port: config.sandboxPort,
host: config.sandboxHost,
allowedOrigins: config.allowedOrigins,
publicUrl: config.sandboxPublicUrl,
});
await sandboxController.start();
// The dedicated origin apps declaring `_meta.ui.domain` are served from
@ -54,6 +55,7 @@ export async function startHonoServer(
const appOriginController = createAppOriginController({
port: config.appOriginPort,
host: config.sandboxHost,
publicOrigin: config.appOriginPublicOrigin,
embedderOrigins: appDocumentEmbedders(
sandboxController.getUrl(),
config.allowedOrigins,

View file

@ -69,6 +69,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
port: config.sandboxPort,
host: config.sandboxHost,
allowedOrigins: config.allowedOrigins,
publicUrl: config.sandboxPublicUrl,
});
await sandboxController.start();
// The dedicated origin apps declaring `_meta.ui.domain` are served from
@ -77,6 +78,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
const appOriginController = createAppOriginController({
port: config.appOriginPort,
host: config.sandboxHost,
publicOrigin: config.appOriginPublicOrigin,
embedderOrigins: appDocumentEmbedders(
sandboxController.getUrl(),
config.allowedOrigins,

View file

@ -21,6 +21,12 @@ import { readInspectorVersionSafe } from "../../../core/node/version.ts";
import { resolveSandboxPort } from "./sandbox-controller.js";
import { resolveAppOriginPort } from "./app-origin-controller.js";
import { resolveBindHostname } from "./resolve-bind-host.js";
import {
APP_ORIGIN_FULL_ADDRESS_ENV,
resolveAppOriginPublicOrigin,
resolveSandboxPublicUrl,
SANDBOX_FULL_ADDRESS_ENV,
} from "./public-address.js";
import {
canonicalUrlHost,
isAllInterfacesHost,
@ -74,6 +80,17 @@ export interface WebServerConfig {
* address the sandbox uses.
*/
appOriginPort: number;
/**
* Public sandbox proxy URL advertised in place of the bind-derived one
* (`MCP_SANDBOX_FULL_ADDRESS`, #1862), or undefined when unset or refused.
* See `public-address.ts` for what is refused and why.
*/
sandboxPublicUrl?: string;
/**
* Public origin published app documents are served under
* (`MCP_APP_ORIGIN_FULL_ADDRESS`, #1862), or undefined when unset or refused.
*/
appOriginPublicOrigin?: string;
logger: Logger | undefined;
/** When true, open browser after server starts. */
autoOpen: boolean;
@ -460,6 +477,22 @@ export function buildWebServerConfig(
}
})
.filter((o): o is string => o !== null);
const allowedOrigins = configuredOrigins?.length
? configuredOrigins
: defaultAllowedOrigins(hostname, port);
// Resolved after the allow-list because both are checked against it: neither
// listener may share an origin with the Inspector page (#1862). The sandbox
// goes first so the app origin can be checked against it too.
const sandboxPublicUrl = resolveSandboxPublicUrl(
process.env[SANDBOX_FULL_ADDRESS_ENV],
allowedOrigins,
);
const appOriginPublicOrigin = resolveAppOriginPublicOrigin(
process.env[APP_ORIGIN_FULL_ADDRESS_ENV],
allowedOrigins,
sandboxPublicUrl,
);
return {
port,
@ -471,12 +504,12 @@ export function buildWebServerConfig(
writable,
initialServers,
storageDir: process.env.MCP_STORAGE_DIR,
allowedOrigins: configuredOrigins?.length
? configuredOrigins
: defaultAllowedOrigins(hostname, port),
allowedOrigins,
sandboxPort,
sandboxHost: hostname,
appOriginPort,
sandboxPublicUrl,
appOriginPublicOrigin,
logger,
autoOpen: resolveAutoOpen(),
};