feat(web): enforce localhost-only binding; expand loopback origin allow-list (#1796)
Closes #1795. Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms. Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
33e88eac9f
commit
cea22d86b8
7 changed files with 1610 additions and 86 deletions
67
clients/web/server/resolve-bind-host.ts
Normal file
67
clients/web/server/resolve-bind-host.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Resolves and validates the hostname the web server (prod backend + Vite dev
|
||||
* server) binds to. Enforces the localhost-only default so the Inspector can't
|
||||
* accidentally expose its process-spawning proxy to the whole network.
|
||||
*
|
||||
* Shared by `web-server-config.ts` (the Node backend) and `vite.config.ts` (the
|
||||
* dev server) so both bind points enforce the same policy.
|
||||
*/
|
||||
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
stripBrackets,
|
||||
} from "../../../core/node/hostUrl.ts";
|
||||
|
||||
/** Env var that opts into binding all network interfaces (see {@link resolveBindHostname}). */
|
||||
export const BIND_ALL_INTERFACES_ENV = "DANGEROUSLY_BIND_ALL_INTERFACES";
|
||||
|
||||
/**
|
||||
* An explicit, unambiguous opt-in. Unlike a bare `!!value` (which treats the
|
||||
* string `"false"` as truthy), only `"true"`/`"1"` (case-insensitive) enable
|
||||
* the override, so `DANGEROUSLY_BIND_ALL_INTERFACES=false` reads as "off".
|
||||
*/
|
||||
function isEnabled(value: string | undefined): boolean {
|
||||
const v = value?.trim().toLowerCase();
|
||||
return v === "true" || v === "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the bind hostname from `env` (default `process.env`), defaulting to
|
||||
* `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their
|
||||
* legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly
|
||||
* enabled — the published Docker image sets it, since a container must bind
|
||||
* `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than
|
||||
* silently binding wide open. The returned value is trimmed and de-bracketed
|
||||
* (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection,
|
||||
* `listen()`, and the origin list all consume the same value; `formatHostForUrl`
|
||||
* re-adds the brackets wherever a URL is built.
|
||||
*/
|
||||
export function resolveBindHostname(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const host = (env.HOST ?? "localhost").trim();
|
||||
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
||||
// Show the resolved address when it differs from the typed spelling — the
|
||||
// guard now catches forms the resolver folds to the wildcard (a fullwidth
|
||||
// `HOST="0"` renders like `0`, `HOST=0` / `0x0` / `::0` bind `0.0.0.0`), and
|
||||
// "bind a loopback host" reads as a non-sequitur without that hint.
|
||||
const resolved = canonicalUrlHost(host);
|
||||
// Compare de-bracketed on BOTH sides so neither a plain `HOST="::"` nor a
|
||||
// bracketed `HOST="[::]"` is reported as "(resolves to [::])" — the
|
||||
// bracketing is a URL-authority detail, not a resolution.
|
||||
const shown =
|
||||
stripBrackets(resolved) === stripBrackets(host.toLowerCase())
|
||||
? `HOST="${host}"`
|
||||
: `HOST="${host}" (resolves to ${resolved})`;
|
||||
throw new Error(
|
||||
`Refusing to bind ${shown}: this exposes the MCP Inspector to your ` +
|
||||
`entire network, and its backend can spawn local processes and connect ` +
|
||||
`to MCP servers on your behalf — the exposure DNS-rebinding attacks ` +
|
||||
`target. Bind a loopback host (localhost / 127.0.0.1) instead. To ` +
|
||||
`override — only inside an isolated container or trusted network — set ` +
|
||||
`${BIND_ALL_INTERFACES_ENV}=true.`,
|
||||
);
|
||||
}
|
||||
return stripBrackets(host);
|
||||
}
|
||||
|
|
@ -18,7 +18,10 @@ import {
|
|||
parseHeaderPair,
|
||||
type ServerConfigOptions,
|
||||
} from "../../../core/mcp/node/config.ts";
|
||||
import { buildWebServerConfig } from "./web-server-config.js";
|
||||
import {
|
||||
buildWebServerConfig,
|
||||
type WebServerConfig,
|
||||
} from "./web-server-config.js";
|
||||
import { startViteDevServer } from "./start-vite-dev-server.js";
|
||||
import { startHonoServer } from "./server.js";
|
||||
import { ensureWebBuild } from "./ensure-web-build.js";
|
||||
|
|
@ -232,12 +235,25 @@ export async function runWeb(argv: string[]): Promise<number> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
const webConfig = buildWebServerConfig({
|
||||
initialMcpConfig,
|
||||
mcpConfigPath,
|
||||
writable,
|
||||
initialServers,
|
||||
});
|
||||
let webConfig: WebServerConfig;
|
||||
try {
|
||||
webConfig = buildWebServerConfig({
|
||||
initialMcpConfig,
|
||||
mcpConfigPath,
|
||||
writable,
|
||||
initialServers,
|
||||
});
|
||||
} catch (err) {
|
||||
// e.g. the bind-host guard refusing HOST=0.0.0.0 — surface the actionable
|
||||
// message rather than a raw stack trace from the launcher's top-level handler.
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: /* v8 ignore next -- buildWebServerConfig only ever throws Error instances; this fallback is a defensive guard for the impossible non-Error throw. */
|
||||
"Invalid web server configuration.";
|
||||
console.error("Error:", message);
|
||||
process.exit(1);
|
||||
}
|
||||
const webRoot = join(__dirname, "..");
|
||||
const distRoot = join(webRoot, "dist");
|
||||
if (!isDev) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ 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";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -15,6 +19,59 @@ export interface SandboxControllerOptions {
|
|||
port: number;
|
||||
/** Host to bind (default localhost). */
|
||||
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[];
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const valid = (allowedOrigins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
|
||||
return `frame-ancestors ${sources.join(" ")}`;
|
||||
}
|
||||
|
||||
export interface SandboxController {
|
||||
|
|
@ -23,27 +80,40 @@ export interface SandboxController {
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
|
||||
* 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.
|
||||
*/
|
||||
export function resolveSandboxPort(): number {
|
||||
const fromSandbox = process.env.MCP_SANDBOX_PORT;
|
||||
if (fromSandbox !== undefined && fromSandbox !== "") {
|
||||
const n = parseInt(fromSandbox, 10);
|
||||
if (!Number.isNaN(n) && n >= 0) return n;
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
const fromServer = process.env.SERVER_PORT;
|
||||
if (fromServer !== undefined && fromServer !== "") {
|
||||
const n = parseInt(fromServer, 10);
|
||||
if (!Number.isNaN(n) && n >= 0) return n;
|
||||
}
|
||||
return 0;
|
||||
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0;
|
||||
}
|
||||
|
||||
export function createSandboxController(
|
||||
options: SandboxControllerOptions,
|
||||
): SandboxController {
|
||||
const { port, host = "localhost" } = options;
|
||||
const { port, host = "localhost", allowedOrigins } = options;
|
||||
let server: Server | null = null;
|
||||
let sandboxUrl: string | null = null;
|
||||
|
||||
|
|
@ -54,10 +124,10 @@ export function createSandboxController(
|
|||
// 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` ensures the
|
||||
// proxy can only be embedded by the local inspector itself.
|
||||
const SANDBOX_PROXY_CSP =
|
||||
"frame-ancestors http://127.0.0.1:* http://localhost:*";
|
||||
// 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 {
|
||||
|
|
@ -132,7 +202,19 @@ export function createSandboxController(
|
|||
string form only occurs for unix-socket/pipe listens, which
|
||||
this controller never performs. */
|
||||
(addr as unknown as number);
|
||||
sandboxUrl = `http://${host}:${actualPort}/sandbox`;
|
||||
// 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;
|
||||
sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`;
|
||||
settle({ port: actualPort, url: sandboxUrl });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { serveStatic } from "@hono/node-server/serve-static";
|
|||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
|
||||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
|
|
@ -39,6 +40,7 @@ export async function startHonoServer(
|
|||
const sandboxController = createSandboxController({
|
||||
port: config.sandboxPort,
|
||||
host: config.sandboxHost,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
});
|
||||
await sandboxController.start();
|
||||
|
||||
|
|
@ -131,7 +133,7 @@ export async function startHonoServer(
|
|||
httpServer.on("error", (err: Error) => {
|
||||
if (err.message.includes("EADDRINUSE")) {
|
||||
console.error(
|
||||
`MCP Inspector PORT IS IN USE at http://${config.hostname}:${config.port}`,
|
||||
`MCP Inspector PORT IS IN USE at http://${formatHostForUrl(config.hostname)}:${config.port}`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ export async function startViteDevServer(
|
|||
server: {
|
||||
port: config.port,
|
||||
host: config.hostname,
|
||||
// `strictPort: true` (matching `vite.config.ts`) so a busy port fails
|
||||
// loudly instead of silently binding a different one — the origin
|
||||
// allow-list and the sandbox `frame-ancestors` are derived from
|
||||
// `config.port`, so a drifted bind would 403 every connect and CSP-block
|
||||
// the MCP Apps iframe while the banner advertises the unusable port.
|
||||
strictPort: true,
|
||||
// Allow Vite to serve source files from the repo root (core/ lives
|
||||
// outside clients/web), matching `vite.config.ts`'s `server.fs.allow`.
|
||||
fs: {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
const sandboxController = createSandboxController({
|
||||
port: config.sandboxPort,
|
||||
host: config.sandboxHost,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
});
|
||||
await sandboxController.start();
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ import {
|
|||
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { readInspectorVersionSafe } from "../../../core/node/version.ts";
|
||||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
||||
import { resolveBindHostname } from "./resolve-bind-host.js";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
} from "../../../core/node/hostUrl.ts";
|
||||
|
||||
// The single-source Inspector version (root package.json), read once at load.
|
||||
// The browser can't read the filesystem the way the CLI/TUI do, so the backend
|
||||
|
|
@ -163,7 +168,19 @@ export function printServerBanner(
|
|||
resolvedToken: string,
|
||||
sandboxUrl: string | undefined,
|
||||
): string {
|
||||
const baseUrl = `http://${config.hostname}:${actualPort}`;
|
||||
// Advertise `localhost` for a wildcard bind (`http://0.0.0.0:PORT` is an
|
||||
// awkward URL to click and points the user at a reachable, allow-listed
|
||||
// address); otherwise use the SAME canonical host the allow-list emits, so the
|
||||
// advertised URL's origin is always a member of `allowedOrigins`
|
||||
// (`banner ⊆ allowedOrigins`) — including the IPv4-mapped case where
|
||||
// `canonicalUrlHost` unmaps but `formatHostForUrl` wouldn't. `httpOrigin`
|
||||
// keeps the banner and the list agreeing on the default-port form (both drop
|
||||
// :80). Computing `h` once keeps the predicate and the value reading the same
|
||||
// form; `isAllInterfacesHost` also canonicalizes internally, so this is
|
||||
// belt-and-braces, not what makes them agree.
|
||||
const h = canonicalUrlHost(config.hostname);
|
||||
const bannerHost = isAllInterfacesHost(h) ? "localhost" : h;
|
||||
const baseUrl = httpOrigin(bannerHost, actualPort);
|
||||
const url =
|
||||
config.dangerouslyOmitAuth || !resolvedToken
|
||||
? baseUrl
|
||||
|
|
@ -196,6 +213,87 @@ export interface BuildWebServerConfigOptions {
|
|||
initialServers?: MCPConfig | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback hostnames that all address the local machine. `localhost` resolves
|
||||
* to *either* `127.0.0.1` (IPv4) or `::1` (IPv6) depending on the OS resolver,
|
||||
* and Node/Vite may bind the IPv6 form — so the browser can legitimately end up
|
||||
* at `http://[::1]:PORT` even though the banner printed `http://localhost:PORT`.
|
||||
* IPv6 is the **bracketed** form only (`[::1]`): the sole caller looks up
|
||||
* `canonicalUrlHost(hostname)`, which always brackets an IPv6 literal.
|
||||
*/
|
||||
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
||||
|
||||
/**
|
||||
* Build an http origin string. The port is omitted when it's the http scheme
|
||||
* default (80): browsers drop the default port from `Origin`, and the guard is
|
||||
* an exact string match, so `http://host:80` could never match a real request.
|
||||
*/
|
||||
function httpOrigin(host: string, port: number): string {
|
||||
return port === 80 ? `http://${host}` : `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
/** The three interchangeable loopback origin forms for a port. */
|
||||
function loopbackOrigins(port: number): string[] {
|
||||
return [
|
||||
httpOrigin("localhost", port),
|
||||
httpOrigin("127.0.0.1", port),
|
||||
httpOrigin("[::1]", port),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The default allowed-origins list for a given bind host/port.
|
||||
*
|
||||
* When the bind host is loopback, `localhost`, `127.0.0.1`, and `[::1]` are
|
||||
* interchangeable ways to reach the same server, so the origin the browser
|
||||
* actually sends is nondeterministic (it depends on how `localhost` resolved
|
||||
* and which family got bound). Returning all three loopback origin forms keeps
|
||||
* the DNS-rebinding guard effective — still scoped to loopback at this exact
|
||||
* port — while not 403-ing a browser that landed on `[::1]` instead of the
|
||||
* `localhost` the banner advertised. The host is canonicalized first
|
||||
* ({@link canonicalUrlHost}), so a non-canonical spelling of a loopback address
|
||||
* still lands in the loopback branch.
|
||||
*
|
||||
* An **all-interfaces** bind (`0.0.0.0` / `::`, the opt-in path the Docker image
|
||||
* uses) also serves loopback, so it gets the loopback trio *plus the canonical
|
||||
* wildcard origins* `http://0.0.0.0:PORT` and `http://[::]:PORT`. Both are
|
||||
* locally connectable, the image/docs describe binding to `0.0.0.0:6274`, and a
|
||||
* dual-stack `::` bind serves IPv4 too — so a browser may send either. We emit
|
||||
* the **canonical** pair rather than the typed `HOST` spelling because browsers
|
||||
* canonicalize the address before building the `Origin` (`HOST=0` / `0x0` /
|
||||
* `0.0.0` all send `http://0.0.0.0:PORT`, `::0` sends `http://[::]:PORT`), so
|
||||
* echoing the raw spelling would emit an entry the browser can never match.
|
||||
* Including these weakens nothing (no attacker document can claim a loopback /
|
||||
* `0.0.0.0` origin without the user having navigated there). Reaching the server
|
||||
* at a non-loopback address — a LAN IP, a public hostname — still needs
|
||||
* `ALLOWED_ORIGINS`, since those origins can't be enumerated from the wildcard.
|
||||
*
|
||||
* For a specific non-loopback host (a real IP/hostname) we return the single
|
||||
* exact origin — canonicalized and, for an IPv6 literal, bracketed, so it
|
||||
* matches the `Origin` header the browser actually sends.
|
||||
*/
|
||||
export function defaultAllowedOrigins(
|
||||
hostname: string,
|
||||
port: number,
|
||||
): string[] {
|
||||
// The loopback lookup and the emitted origin both read the canonicalized `h`
|
||||
// (the wildcard check via `isAllInterfacesHost` canonicalizes internally), so
|
||||
// every branch reasons about the same address.
|
||||
const h = canonicalUrlHost(hostname);
|
||||
if (LOOPBACK_HOSTNAMES.has(h)) {
|
||||
return loopbackOrigins(port);
|
||||
}
|
||||
if (isAllInterfacesHost(h)) {
|
||||
return [
|
||||
...loopbackOrigins(port),
|
||||
httpOrigin("0.0.0.0", port),
|
||||
httpOrigin("[::]", port),
|
||||
];
|
||||
}
|
||||
// `h` is already the canonical, URL-ready host (bracketed for IPv6).
|
||||
return [httpOrigin(h, port)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WebServerConfig from process.env and optional initial MCP server config.
|
||||
* Used by the launcher runner, Vite dev (`buildWebServerConfigFromEnv`), and prod standalone.
|
||||
|
|
@ -209,9 +307,21 @@ export function buildWebServerConfig(
|
|||
writable = true,
|
||||
initialServers = null,
|
||||
} = options;
|
||||
const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10);
|
||||
const hostname = process.env.HOST ?? "localhost";
|
||||
const baseUrl = `http://${hostname}:${port}`;
|
||||
// Treat an empty CLIENT_PORT as unset (matches resolveSandboxPort's handling
|
||||
// of MCP_SANDBOX_PORT / SERVER_PORT), then require a fixed port: the origin
|
||||
// allow-list and the sandbox CSP are both built from it, so `0` (OS-assigned),
|
||||
// a non-numeric value, or trailing garbage would make them reference a port
|
||||
// the server isn't on — every connect 403s and the MCP Apps iframe is blocked.
|
||||
// The `^\d+$` test rejects `parseInt`'s partial parses (`6274abc`, `80.9`).
|
||||
// Fail fast with an actionable message (run-web surfaces it cleanly).
|
||||
const rawPort = process.env.CLIENT_PORT?.trim() || "6274";
|
||||
const port = parseInt(rawPort, 10);
|
||||
if (!/^\d+$/.test(rawPort) || port < 1 || port > 65535) {
|
||||
throw new Error(
|
||||
`Invalid CLIENT_PORT="${process.env.CLIENT_PORT}": the web server needs a fixed port in 1–65535 (0 / dynamic is unsupported — the origin allow-list and sandbox CSP are derived from it).`,
|
||||
);
|
||||
}
|
||||
const hostname = resolveBindHostname();
|
||||
const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH;
|
||||
const authToken = dangerouslyOmitAuth
|
||||
? ""
|
||||
|
|
@ -233,6 +343,48 @@ export function buildWebServerConfig(
|
|||
);
|
||||
}
|
||||
|
||||
// Parse ALLOWED_ORIGINS into canonical origins. Each entry is normalized via
|
||||
// `new URL(o).origin` (drops a trailing slash/path and any userinfo, lowercases
|
||||
// and punycodes the host, drops the default :80) so the natural copy-paste
|
||||
// forms — `http://localhost:6274/` from the address bar, an uppercase or IDN
|
||||
// host, an explicit `:80` — match the canonical `Origin` the browser sends
|
||||
// rather than 403ing on an exact-string compare. Unparseable, opaque, and
|
||||
// wildcard entries are warned and dropped (see inline).
|
||||
//
|
||||
// When nothing survives (unset, `""`, `" "`, `","`, or all-invalid) we fall
|
||||
// back to `defaultAllowedOrigins` below — critically NOT to `[]`, which the
|
||||
// origin middleware treats as *allow-all*, silently disabling the guard.
|
||||
const configuredOrigins = process.env.ALLOWED_ORIGINS?.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean)
|
||||
.map((o) => {
|
||||
try {
|
||||
const { origin } = new URL(o);
|
||||
// A scheme-less entry (`localhost:6274`) doesn't throw — `new URL` reads
|
||||
// the host as the scheme and `.origin` is the literal string "null"
|
||||
// (also non-special schemes: `file:`, `about:`, `javascript:`, `data:`,
|
||||
// and browser-extension schemes like `chrome-extension:`). Reject it:
|
||||
// it's not the origin the user meant, AND "null" is a real header value
|
||||
// browsers send from opaque origins (a sandboxed iframe, a `data:` doc),
|
||||
// so allow-listing it would erode the guard. Entries need an http(s)
|
||||
// scheme — the app is served over http(s), and a browser's `Origin` on
|
||||
// any request (including a WebSocket handshake) is its page's http(s)
|
||||
// origin, never a `ws:` one, so a `ws://` entry could never match anyway.
|
||||
if (origin === "null") throw new Error("opaque origin");
|
||||
// A wildcard (`http://*.example.com`) survives `new URL` but can never
|
||||
// match the exact-compare origin guard — yet `*.example.com` IS a legal
|
||||
// CSP host-source, so it would silently work for the sandbox iframe and
|
||||
// silently 403 every connect (the most confusing split). Reject it so it
|
||||
// fails loudly and consistently; list exact origins instead.
|
||||
if (origin.includes("*")) throw new Error("wildcard origin");
|
||||
return origin;
|
||||
} catch {
|
||||
console.warn(`Ignoring invalid ALLOWED_ORIGINS entry: ${o}`);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((o): o is string => o !== null);
|
||||
|
||||
return {
|
||||
port,
|
||||
hostname,
|
||||
|
|
@ -243,9 +395,9 @@ export function buildWebServerConfig(
|
|||
writable,
|
||||
initialServers,
|
||||
storageDir: process.env.MCP_STORAGE_DIR,
|
||||
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? [
|
||||
baseUrl,
|
||||
],
|
||||
allowedOrigins: configuredOrigins?.length
|
||||
? configuredOrigins
|
||||
: defaultAllowedOrigins(hostname, port),
|
||||
sandboxPort,
|
||||
sandboxHost: hostname,
|
||||
logger,
|
||||
|
|
|
|||
Loading…
Reference in a new issue