inspector/clients/web/server/sandbox-controller.ts
cliffhall 925461a043 fix(web): only use a public address on a fixed listener port (#1862)
Addresses Copilot review round 2. A reverse proxy routes a public address to
one stable port, so neither MCP_SANDBOX_FULL_ADDRESS nor
MCP_APP_ORIGIN_FULL_ADDRESS is used when the listener's port is OS-assigned:
an explicit 0, a config-time collision resolved to 0, or the EADDRINUSE
fallback. Validation warnings also stop echoing the URL path.

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 01:10:29 -04:00

379 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Sandbox server controller: start/close and get URL.
* Used by server.ts (prod) and the Vite plugin (dev/test). Same process lifecycle as the main server.
*/
import { createServer, type Server } from "node:http";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
canonicalUrlHost,
isAllInterfacesHost,
} from "../../../core/node/hostUrl.ts";
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface SandboxControllerOptions {
/** Port to bind (0 = dynamic). */
port: number;
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
host?: string;
/**
* The backend's origin allow-list (the embedder origins). Used to build the
* proxy's `frame-ancestors` so the sandbox iframe isn't CSP-blocked when the
* inspector is served on a non-loopback host (network hosting / Docker). The
* real callers always pass a non-empty list (`buildWebServerConfig` never
* produces an empty `allowedOrigins` since #1795's H1 fix); an empty/omitted
* 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
// a valid CSP `host-source`** (the CSP3 `host-char` grammar is ALPHA/DIGIT/"-"
// only). Chromium rejects `http://[::1]:*` and, if it's the sole source, the
// directive degrades to `frame-ancestors 'none'` and blocks the frame — verified
// empirically. So MCP Apps requires browsing the app at a name or IPv4
// (`localhost` / `127.0.0.1`), which these two cover; a bare `[::1]` embedder
// can't be admitted by any frame-ancestors source and is unsupported for Apps.
const LOOPBACK_FRAME_ANCESTORS = ["http://127.0.0.1:*", "http://localhost:*"];
/**
* A well-formed CSP host-source: `scheme://host[:port]` with no whitespace, CSP
* metacharacters, or brackets. `allowedOrigins` comes from
* `web-server-config.ts`, where every entry is already a `new URL().origin`
* (canonical, no path/newline/`;`), so its **load-bearing job today is dropping
* bracketed IPv6** — a `http://[::1]:PORT` literal isn't a valid CSP host-source
* and, as the only source, would collapse the directive to `'none'`. The
* whitespace/metacharacter exclusions are belt-and-braces: `sandboxFrameAncestors`
* takes the list from a caller, not from env directly, so a future caller that
* doesn't pre-canonicalize can't corrupt or widen the header — `*` is excluded
* too, so a `http://*.example.com` entry (which the origin allow-list already
* rejects, but a future caller might not) can't broaden the embedder set. This
* filters the caller-supplied `allowedOrigins` only; {@link LOOPBACK_FRAME_ANCESTORS}
* is a trusted constant that deliberately uses the `:*` port-wildcard and is
* emitted as-is (it does NOT pass this regex, by design).
*/
const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]*]+$/i;
/**
* The proxy's `frame-ancestors` sources. The embedder is the inspector app
* page, whose origin is (by construction) in the backend's `allowedOrigins`, so
* derive the directive from that list — this keeps the MCP Apps iframe working
* on whatever host the app is actually served from, not just loopback.
* Malformed entries are dropped (see {@link CSP_HOST_SOURCE}); if nothing valid
* remains (the real backend always passes a non-empty list, so this is a
* hand-constructed caller, a test, or an all-malformed `ALLOWED_ORIGINS`), it
* falls back to the loopback family so the sandbox still loads locally and the
* header can't be corrupted.
*/
export function sandboxFrameAncestors(allowedOrigins?: string[]): string {
return frameAncestorsDirective(allowedOrigins);
}
/**
* Build a `frame-ancestors` directive from a caller-supplied origin list,
* dropping malformed entries ({@link CSP_HOST_SOURCE}) and falling back to
* {@link LOOPBACK_FRAME_ANCESTORS} when nothing valid remains.
*
* Shared with the app-origin server (#2056), which restricts a published app
* document to being framed by the sandbox proxy and the Inspector page above
* it. Same grammar, same fallback — an all-invalid list yields the loopback
* family rather than an empty source list, since a `frame-ancestors` with no
* sources means `'none'` and would block the frame outright — so the two
* derive it here rather than each rolling one.
*/
export function frameAncestorsDirective(origins?: string[]): string {
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>;
getUrl(): string | null;
}
/**
* A usable listen port from a raw env value, or `undefined` if it isn't a plain
* integer in `0`–`65535` (0 = OS-assigned). The `^\d+$` test rejects `parseInt`
* partial parses (`6274abc`) and the upper bound keeps an out-of-range value
* (`70000`) from reaching `server.listen`, which throws `ERR_SOCKET_BAD_PORT`
* synchronously — matching the strict `CLIENT_PORT` validation.
*/
function parseListenPort(raw: string | undefined): number | undefined {
const v = raw?.trim();
if (!v || !/^\d+$/.test(v)) return undefined;
const n = parseInt(v, 10);
return n <= 65535 ? n : undefined;
}
/**
* The default sandbox listen port — **fixed**, deliberately not `0`.
*
* A `0` default lets the OS assign, which is fine on a desktop (the browser
* learns the port from `/api/config`) but unusable anywhere the port has to be
* declared *before* the process starts: a dev container's `forwardPorts`, a
* `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run,
* so it could never be named in that config and the Apps tab was unreachable
* (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same
* reason; this is its sandbox counterpart.
*
* `6275` sits next to the web port so the pair is easy to forward together. It
* is not a v1 carry-over — v2 has no proxy server on it.
*/
export const DEFAULT_SANDBOX_PORT = 6275;
/**
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT →
* {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than
* crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is
* warned so the fall-through isn't silent — matching the warn-and-drop
* precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the
* OS assign" ({@link parseListenPort} accepts it).
*/
export function resolveSandboxPort(): number {
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
if (fromSandbox === undefined && process.env.MCP_SANDBOX_PORT?.trim()) {
console.warn(
`Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`,
);
}
return (
fromSandbox ??
parseListenPort(process.env.SERVER_PORT) ??
DEFAULT_SANDBOX_PORT
);
}
export function createSandboxController(
options: SandboxControllerOptions,
): SandboxController {
// Defaulted to the same address the web server binds, never the name
// `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, 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
// deliberately omitted because a `srcdoc` iframe clones its embedder's CSP
// policy container: any fetch directive on this header would be inherited by
// the inner app document and, since multiple CSPs intersect, would override
// the per-app `connect-src`/`img-src` allowlists the host bakes into the
// wrapped HTML (see src/utils/sandbox-csp.ts). The opaque-origin sandbox on
// the inner frame is the structural boundary; `frame-ancestors` restricts the
// proxy to being embedded by the inspector app itself — see
// `sandboxFrameAncestors` for how the embedder origins are derived.
const SANDBOX_PROXY_CSP = sandboxFrameAncestors(allowedOrigins);
let sandboxHtml: string;
try {
const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html");
sandboxHtml = renderSandboxProxyHtml(
readFileSync(sandboxHtmlPath, "utf-8"),
allowedOrigins,
);
} catch (e) {
sandboxHtml =
"<!DOCTYPE html><html><body>Sandbox not loaded: " +
String((e as Error).message) +
"</body></html>";
}
return {
async start(): Promise<{ port: number; url: string }> {
if (server && sandboxUrl) {
return { port: boundPort, url: sandboxUrl };
}
return new Promise((resolve) => {
// Guard so a `listen` error followed by a (theoretically possible)
// late `listening` callback doesn't double-resolve the promise. The
// first signal wins.
let settled = false;
const settle = (value: { port: number; url: string }) => {
/* v8 ignore next -- defensive double-settle guard: for a TCP listen
the `error` and `listening` events are mutually exclusive, so the
second-signal early-return is unreachable in practice. */
if (settled) return;
settled = true;
resolve(value);
};
server = createServer((req, res) => {
if (
req.method !== "GET" ||
(req.url !== "/sandbox" && req.url !== "/sandbox/")
) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
return;
}
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
"Content-Security-Policy": SANDBOX_PROXY_CSP,
});
res.end(sandboxHtml);
});
// A taken port used to mean "no sandbox at all", which was tolerable
// only because the default was dynamic and therefore never collided.
// Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector —
// or anything else already on 6275 — would take the Apps tab down, so
// retry once on an OS-assigned port. The retry is announced loudly:
// whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin
// didn't take, and a silently-moved port is exactly the failure this
// default was introduced to fix.
let retriedDynamic = false;
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
if (!retriedDynamic && port !== 0) {
retriedDynamic = true;
console.warn(
`Sandbox: port ${port} in use; falling back to an OS-assigned port. ` +
`The MCP Apps tab will work locally, but the sandbox is no longer ` +
`on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` +
`need to forward it (container / SSH tunnel / reverse proxy).`,
);
server!.listen(0, host);
return;
}
console.error(
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
);
} else {
console.error("Sandbox server error:", err);
}
// Best-effort degradation: resolve with empty values rather than
// rejecting so callers (the Vite plugin in particular) keep
// attaching `/api/*`. `sandboxUrl` stays null, `getUrl()` keeps
// returning null, and the banner omits the sandbox line.
server = null;
settle({ port: 0, url: "" });
});
server.listen(port, host, () => {
const addr = server!.address();
const actualPort =
typeof addr === "object" && addr !== null && "port" in addr
? addr.port
: /* v8 ignore next -- unreachable for a TCP listen: `address()`
always returns an `AddressInfo` object with `port`. The
string form only occurs for unix-socket/pipe listens, which
this controller never performs. */
(addr as unknown as number);
// Advertise `localhost` for a wildcard bind: the sandbox URL is
// served to the browser (via /api/config) and printed in the banner,
// and `http://0.0.0.0:PORT` isn't reachable from the client — but a
// wildcard bind serves loopback, so `localhost` is. Otherwise use the
// same canonical host the origin allow-list emits, so the served URL
// is reachable and its origin is allow-listed (matches the app banner).
// (`isAllInterfacesHost` canonicalizes internally too, so passing the
// pre-canonicalized host is belt-and-braces.)
const canonicalHost = canonicalUrlHost(host);
const urlHost = isAllInterfacesHost(canonicalHost)
? "localhost"
: canonicalHost;
const derivedUrl = `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 — and only
// when that listener is on a FIXED port. A reverse proxy routes the
// public URL to one stable port; an OS-assigned port (an explicit
// `0`, a config-time collision resolved to `0`, or the EADDRINUSE
// fallback, where someone else holds the pinned port) is not it, so
// advertising the public URL would send the browser to the wrong
// listener or none. The derived URL at least works locally.
const onFixedPort = port !== 0 && !retriedDynamic;
if (publicUrl && !onFixedPort) {
console.warn(
`Sandbox: not advertising MCP_SANDBOX_FULL_ADDRESS, since the sandbox is not on a fixed ` +
`port for it to route to; advertising ${derivedUrl} instead.`,
);
}
sandboxUrl = publicUrl && onFixedPort ? publicUrl : derivedUrl;
boundPort = actualPort;
settle({ port: actualPort, url: sandboxUrl });
});
});
},
async close(): Promise<void> {
if (!server) return;
return new Promise((resolve) => {
server!.close(() => {
server = null;
sandboxUrl = null;
resolve();
});
});
},
getUrl(): string | null {
return sandboxUrl;
},
};
}