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>
379 lines
17 KiB
TypeScript
379 lines
17 KiB
TypeScript
/**
|
||
* 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;
|
||
},
|
||
};
|
||
}
|