inspector/clients/web/server/public-address.ts
cliffhall 4dc5eaa6bb fix(web): address Copilot review round 1 on public addresses (#1862)
- Never echo a refused value into a warning: credentials, query and
  fragment are dropped, and an unparseable value is not shown at all.
- Do not adopt MCP_SANDBOX_FULL_ADDRESS / MCP_APP_ORIGIN_FULL_ADDRESS after
  the EADDRINUSE fallback: the reverse proxy still routes them to the pinned
  port, which another process holds.
- Refuse a public app origin equal to any trusted ancestor in the controller,
  where the real (possibly bind-derived, possibly fallback-port) sandbox
  origin is known; config time could not see it.

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>
2026-09-14 00:37:50 -04:00

219 lines
8.6 KiB
TypeScript

/**
* 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 };
}
/**
* The value as it may appear in a log line. Never the raw string: the values
* refused for carrying credentials or a query are exactly the ones likely to
* hold a secret, and echoing them would leak what the check refused. Userinfo,
* query and fragment are dropped; an unparseable value is not echoed at all.
*/
export function describeForLog(raw: string): string {
try {
const u = new URL(raw);
return `"${u.protocol}${u.host ? `//${u.host}` : ""}${u.pathname}"`;
} catch {
return "(unparseable value, not echoed)";
}
}
function warnIgnored(env: string, raw: string, reason: string): void {
console.warn(
`Ignoring ${env}=${describeForLog(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 overridden sandbox URL, when there is one. This is the
* early, config-time check; a bind-derived sandbox origin is only known once
* that listener binds (possibly on a fallback port), so the authoritative
* comparison against it is made by the app-origin controller at start, which
* receives the real sandbox and Inspector origins as `embedderOrigins`.
*/
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;
}