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:
Cliff Hall 2026-07-26 22:15:10 -04:00 • committed by GitHub
commit cea22d86b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1610 additions and 86 deletions

View 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);
}

View file

@ -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) {

View file

@ -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 });
});
});

View file

@ -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 {

View file

@ -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: {

View file

@ -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();

View file

@ -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,