From 84036d76675d4eede585d303b2c1f8822bab0228 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 16:08:53 -0400 Subject: [PATCH 01/31] feat(web): enforce localhost-only binding; expand loopback origin allow-list (#1795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refuse binding an all-interfaces host (0.0.0.0 / :: / empty) by default — it exposes the process-spawning backend to the local network, the surface DNS-rebinding attacks target — unless DANGEROUSLY_BIND_ALL_INTERFACES=true is set. A new shared guard (server/resolve-bind-host.ts) is used by both bind points (web-server-config.ts and vite.config.ts). The Docker image, which must bind 0.0.0.0 to be reachable via -p, sets the opt-in flag. Also fixes the original bug: connecting to a stdio server 403'd with "Invalid origin ... DNS rebinding" because the dev server binds IPv6 loopback [::1] while the default allowedOrigins was the single literal "http://localhost:PORT". The default loopback origin list now expands to all three interchangeable forms (localhost / 127.0.0.1 / [::1]). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- Dockerfile | 5 +- README.md | 2 +- clients/web/README.md | 6 ++ clients/web/server/resolve-bind-host.ts | 60 ++++++++++++++++ clients/web/server/web-server-config.ts | 44 ++++++++++-- .../server/resolve-bind-host.test.ts | 65 +++++++++++++++++ .../server/web-server-config.test.ts | 72 +++++++++++++++++-- clients/web/vite.config.ts | 3 +- 8 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 clients/web/server/resolve-bind-host.ts create mode 100644 clients/web/src/test/integration/server/resolve-bind-host.test.ts diff --git a/Dockerfile b/Dockerfile index 0651b7d28..986231a09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,11 @@ COPY --from=builder /build/modelcontextprotocol-inspector-*.tgz /tmp/inspector.t RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz # Serve on all interfaces so the UI is reachable from outside the container, and -# never try to open a browser from inside it. +# never try to open a browser from inside it. Binding 0.0.0.0 is refused by +# default (it exposes the process-spawning backend to the network); a container +# is the sanctioned exception, so opt in explicitly via DANGEROUSLY_BIND_ALL_INTERFACES. ENV HOST=0.0.0.0 \ + DANGEROUSLY_BIND_ALL_INTERFACES=true \ CLIENT_PORT=6274 \ MCP_AUTO_OPEN_ENABLED=false EXPOSE 6274 diff --git a/README.md b/README.md index a8b358ce7..82097a7d3 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ docker build -t mcp-inspector . docker run --rm -p 6274:6274 mcp-inspector ``` -The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). ## Contributing — `AGENTS.md` and `CLAUDE.md` diff --git a/clients/web/README.md b/clients/web/README.md index 6fb04b682..f3f57bd2a 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -159,6 +159,12 @@ Storybook is first-class here because the components are presentational — each The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer `. The browser recovers the token, in priority order (see `App.tsx` `getAuthToken()`): the `window.__INSPECTOR_API_TOKEN__` global injected into `index.html` on every page load (`server/inject-auth-token.ts`), then a `?MCP_INSPECTOR_API_TOKEN=…` query param, then `sessionStorage`. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`). See the root [AGENTS.md](../../AGENTS.md) for the full rationale. +## Host binding & the origin allow-list + +Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, or empty) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. + +The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override. + ## HTTP proxy support The web backend connects to remote MCP servers through the shared Node transport (`core/mcp/node/transport.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing is powered by [`undici`](https://www.npmjs.com/package/undici)'s `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail. diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts new file mode 100644 index 000000000..a397a92f5 --- /dev/null +++ b/clients/web/server/resolve-bind-host.ts @@ -0,0 +1,60 @@ +/** + * 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. + */ + +/** Env var that opts into binding all network interfaces (see {@link resolveBindHostname}). */ +export const BIND_ALL_INTERFACES_ENV = "DANGEROUSLY_BIND_ALL_INTERFACES"; + +/** + * Hostnames that bind *every* network interface rather than just loopback: + * `0.0.0.0` (IPv4 wildcard), `::` (IPv6 wildcard), and the empty string (Node's + * `listen()` treats "" as the unspecified/all-interfaces address). Binding any + * of these makes the backend — which can spawn local processes and reach MCP + * servers on behalf of the browser — reachable from the local network, which is + * the exact exposure DNS-rebinding attacks exploit. + */ +const ALL_INTERFACES_HOSTS = new Set(["0.0.0.0", "::", ""]); + +/** True when `host` binds all interfaces rather than loopback only. */ +export function isAllInterfacesHost(host: string): boolean { + return ALL_INTERFACES_HOSTS.has(host.trim().toLowerCase()); +} + +/** + * 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) 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. + */ +export function resolveBindHostname( + env: NodeJS.ProcessEnv = process.env, +): string { + const host = env.HOST ?? "localhost"; + if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) { + throw new Error( + `Refusing to bind HOST="${host}": 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 host; +} diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index d40a18ac9..6d3890056 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -16,6 +16,7 @@ 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"; // 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 @@ -196,6 +197,40 @@ 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`. + */ +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +/** + * 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. For a non-loopback host (e.g. `0.0.0.0` or + * a real hostname) we return the single exact origin, unchanged. + */ +export function defaultAllowedOrigins( + hostname: string, + port: number, +): string[] { + if (LOOPBACK_HOSTNAMES.has(hostname.toLowerCase())) { + return [ + `http://localhost:${port}`, + `http://127.0.0.1:${port}`, + `http://[::1]:${port}`, + ]; + } + return [`http://${hostname}:${port}`]; +} + /** * Build WebServerConfig from process.env and optional initial MCP server config. * Used by the launcher runner, Vite dev (`buildWebServerConfigFromEnv`), and prod standalone. @@ -210,8 +245,7 @@ export function buildWebServerConfig( initialServers = null, } = options; const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10); - const hostname = process.env.HOST ?? "localhost"; - const baseUrl = `http://${hostname}:${port}`; + const hostname = resolveBindHostname(); const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; const authToken = dangerouslyOmitAuth ? "" @@ -243,9 +277,9 @@ export function buildWebServerConfig( writable, initialServers, storageDir: process.env.MCP_STORAGE_DIR, - allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? [ - baseUrl, - ], + allowedOrigins: + process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? + defaultAllowedOrigins(hostname, port), sandboxPort, sandboxHost: hostname, logger, diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts new file mode 100644 index 000000000..0ecb078cc --- /dev/null +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { + BIND_ALL_INTERFACES_ENV, + isAllInterfacesHost, + resolveBindHostname, +} from "../../../../server/resolve-bind-host.js"; + +describe("isAllInterfacesHost", () => { + it.each(["0.0.0.0", "::", "", " 0.0.0.0 ", "::"])( + "flags the all-interfaces host %j", + (host) => { + expect(isAllInterfacesHost(host)).toBe(true); + }, + ); + + it.each(["localhost", "127.0.0.1", "::1", "[::1]", "example.com"])( + "does not flag the loopback/named host %j", + (host) => { + expect(isAllInterfacesHost(host)).toBe(false); + }, + ); +}); + +describe("resolveBindHostname", () => { + it("defaults to localhost when HOST is unset", () => { + expect(resolveBindHostname({})).toBe("localhost"); + }); + + it("returns a loopback HOST unchanged", () => { + expect(resolveBindHostname({ HOST: "127.0.0.1" })).toBe("127.0.0.1"); + }); + + it.each(["0.0.0.0", "::", ""])( + "refuses the all-interfaces host %j without the opt-in", + (host) => { + expect(() => resolveBindHostname({ HOST: host })).toThrow( + new RegExp(BIND_ALL_INTERFACES_ENV), + ); + }, + ); + + it.each(["true", "TRUE", "1", " true "])( + "allows 0.0.0.0 when the opt-in is %j", + (flag) => { + expect( + resolveBindHostname({ + HOST: "0.0.0.0", + [BIND_ALL_INTERFACES_ENV]: flag, + }), + ).toBe("0.0.0.0"); + }, + ); + + it.each(["false", "0", "", "yes", "no"])( + "still refuses 0.0.0.0 when the opt-in reads %j", + (flag) => { + expect(() => + resolveBindHostname({ + HOST: "0.0.0.0", + [BIND_ALL_INTERFACES_ENV]: flag, + }), + ).toThrow(); + }, + ); +}); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 2b407118e..7a9eff046 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { buildWebServerConfig, buildWebServerConfigFromEnv, + defaultAllowedOrigins, printServerBanner, webServerConfigToInitialPayload, type WebServerConfig, @@ -20,6 +21,7 @@ const MUTATED_ENV_KEYS = [ "CLIENT_PORT", "HOST", "DANGEROUSLY_OMIT_AUTH", + "DANGEROUSLY_BIND_ALL_INTERFACES", "MCP_STORAGE_DIR", "ALLOWED_ORIGINS", "MCP_SANDBOX_PORT", @@ -75,7 +77,11 @@ describe("buildWebServerConfigFromEnv", () => { expect(cfg.dangerouslyOmitAuth).toBe(false); expect(cfg.initialMcpConfig).toBeNull(); expect(cfg.storageDir).toBeUndefined(); - expect(cfg.allowedOrigins).toEqual(["http://localhost:6274"]); + expect(cfg.allowedOrigins).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); expect(cfg.sandboxPort).toBe(0); expect(cfg.sandboxHost).toBe("localhost"); expect(cfg.logger).toBeUndefined(); @@ -85,15 +91,47 @@ describe("buildWebServerConfigFromEnv", () => { expect(cfg.autoOpen).toBe(false); }); - it("honors CLIENT_PORT and HOST", () => { + it("honors CLIENT_PORT and a loopback HOST", () => { process.env.CLIENT_PORT = "8123"; - process.env.HOST = "0.0.0.0"; + process.env.HOST = "127.0.0.1"; const cfg = buildWebServerConfigFromEnv(); expect(cfg.port).toBe(8123); + expect(cfg.hostname).toBe("127.0.0.1"); + }); + + it("refuses HOST=0.0.0.0 without the bind-all opt-in", () => { + process.env.HOST = "0.0.0.0"; + expect(() => buildWebServerConfigFromEnv()).toThrow( + /DANGEROUSLY_BIND_ALL_INTERFACES/, + ); + }); + + it("allows HOST=0.0.0.0 when the bind-all opt-in is set", () => { + process.env.CLIENT_PORT = "8123"; + process.env.HOST = "0.0.0.0"; + process.env.DANGEROUSLY_BIND_ALL_INTERFACES = "true"; + const cfg = buildWebServerConfigFromEnv(); expect(cfg.hostname).toBe("0.0.0.0"); expect(cfg.allowedOrigins).toEqual(["http://0.0.0.0:8123"]); }); + it("expands a loopback HOST into all equivalent loopback origins", () => { + // `localhost` resolves to either 127.0.0.1 or ::1 depending on the OS, and + // Node/Vite may bind the IPv6 form — so a browser can send `Origin: + // http://[::1]:PORT` even though the banner advertised `localhost`. The + // default must accept all three so the DNS-rebinding guard doesn't 403 a + // legitimate loopback connect (the exact bug behind an stdio server that + // "should always work" failing with a 403 Invalid origin). + process.env.HOST = "127.0.0.1"; + process.env.CLIENT_PORT = "6274"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.allowedOrigins).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); + }); + it("clears authToken when DANGEROUSLY_OMIT_AUTH is set even if AUTH_TOKEN is present", () => { process.env.DANGEROUSLY_OMIT_AUTH = "1"; process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "ignored"; @@ -196,6 +234,28 @@ describe("buildWebServerConfigFromEnv", () => { }); }); +describe("defaultAllowedOrigins", () => { + it.each(["localhost", "127.0.0.1", "::1", "[::1]", "LOCALHOST"])( + "expands the loopback host %s into all three loopback origins", + (host) => { + expect(defaultAllowedOrigins(host, 6274)).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); + }, + ); + + it("returns a single exact origin for a non-loopback host", () => { + expect(defaultAllowedOrigins("0.0.0.0", 8123)).toEqual([ + "http://0.0.0.0:8123", + ]); + expect(defaultAllowedOrigins("example.com", 80)).toEqual([ + "http://example.com:80", + ]); + }); +}); + describe("buildWebServerConfig", () => { it("matches buildWebServerConfigFromEnv when initialMcpConfig is omitted", () => { process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "shared"; @@ -239,7 +299,11 @@ describe("buildWebServerConfig", () => { const cfg = buildWebServerConfig({ initialMcpConfig }); expect(cfg.port).toBe(7000); expect(cfg.initialMcpConfig).toEqual(initialMcpConfig); - expect(cfg.allowedOrigins).toEqual(["http://localhost:7000"]); + expect(cfg.allowedOrigins).toEqual([ + "http://localhost:7000", + "http://127.0.0.1:7000", + "http://[::1]:7000", + ]); }); it("preserves remote transport initialMcpConfig", () => { diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 9d5c0d596..d44d6c64d 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -13,6 +13,7 @@ import { getViteDevOptimizeDeps, } from "./server/vite-base-config"; import { buildWebServerConfigFromEnv } from "./server/web-server-config"; +import { resolveBindHostname } from "./server/resolve-bind-host"; import { createBrowserExternalizedBuiltinGate } from "./server/browser-externalized-builtin-gate"; import { vitestSharedPaths } from "../../vitest.shared.mts"; const dirname = @@ -157,7 +158,7 @@ export default defineConfig(({ command }) => { // pointing at the wrong host and break browser fetches). server: { port: parseInt(process.env.CLIENT_PORT ?? "6274", 10), - host: process.env.HOST ?? "localhost", + host: resolveBindHostname(), strictPort: true, fs: { allow: [path.resolve(dirname, "../..")], From 1b08a2d26a712b91fbfd10943b7969cf59702dc1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 16:15:54 -0400 Subject: [PATCH 02/31] =?UTF-8?q?docs(web):=20add=20'Hosting=20on=20a=20ne?= =?UTF-8?q?twork'=20note=20=E2=80=94=20specific-IP=20bind=20+=20ALLOWED=5F?= =?UTF-8?q?ORIGINS=20guidance=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/clients/web/README.md b/clients/web/README.md index f3f57bd2a..feea71d38 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -165,6 +165,16 @@ Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (` The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override. +### Hosting on a network + +The guard blocks only the **wildcard** all-interfaces addresses. Binding a **specific** IP or hostname is allowed with no opt-in — that's a single, deliberate exposure, unlike the wildcard which binds every interface at once (the pattern DNS-rebinding exploits). To serve the Inspector on a LAN or the internet: + +- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP), a public IP, or a resolvable hostname all work directly. The default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. +- **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. +- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`) you **must** also set `ALLOWED_ORIGINS`: the default becomes `http://0.0.0.0:PORT`, which no browser ever sends as its `Origin`, so connects would 403. List the origin(s) clients actually use: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. + +In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. + ## HTTP proxy support The web backend connects to remote MCP servers through the shared Node transport (`core/mcp/node/transport.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing is powered by [`undici`](https://www.npmjs.com/package/undici)'s `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail. From 5995c620844d9b1229649d05ddb986c591a1abd4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 16:32:04 -0400 Subject: [PATCH 03/31] review: harden bind guard + IPv6 loopback consistency across origin/CSP/URLs (#1795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the @claude review of #1796: - sandbox CSP: add http://[::1]:* to frame-ancestors so the MCP Apps iframe isn't blocked when the browser is on the IPv6 loopback (the same case the origin fix targets). - resolve-bind-host: catch the wildcard bypasses HOST=0 / 0x0.0.0.0 / ::ffff:0.0.0.0 / dotted-all-zero (inet_aton spellings Node still binds as 0.0.0.0); trim the returned host so detection and the bind value agree. - add shared formatHostForUrl() (brackets IPv6) and use it in the origin allow-list, the startup banner, and the sandbox URL; lowercase a non-loopback origin host so it matches the browser's Origin. - run-web: wrap buildWebServerConfig so the guard's message prints as a clean "Error: …" line instead of a raw stack trace. - vite.config: only enforce the guard for `command === "serve"`, so an ambient HOST=0.0.0.0 doesn't fail `vite build` / vitest at config load. - ALLOWED_ORIGINS: trim entries so a spaced comma list still matches. - docs: note that `--dev` additionally enforces Vite's server.allowedHosts, so a named host needs configuring there (or use the prod server / a specific IP). - tests: cover all of the above. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 4 +- clients/web/server/resolve-bind-host.ts | 75 +++++++++++++++---- clients/web/server/run-web.ts | 22 ++++-- clients/web/server/sandbox-controller.ts | 10 ++- clients/web/server/web-server-config.ts | 15 ++-- .../server/resolve-bind-host.test.ts | 60 ++++++++++++--- .../server/web-server-config.test.ts | 27 ++++++- clients/web/vite.config.ts | 9 ++- 8 files changed, 179 insertions(+), 43 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index feea71d38..af2994448 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -169,10 +169,12 @@ The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOri The guard blocks only the **wildcard** all-interfaces addresses. Binding a **specific** IP or hostname is allowed with no opt-in — that's a single, deliberate exposure, unlike the wildcard which binds every interface at once (the pattern DNS-rebinding exploits). To serve the Inspector on a LAN or the internet: -- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP), a public IP, or a resolvable hostname all work directly. The default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. +- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. - **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`) you **must** also set `ALLOWED_ORIGINS`: the default becomes `http://0.0.0.0:PORT`, which no browser ever sends as its `Origin`, so connects would 403. List the origin(s) clients actually use: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. +The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts but **rejects a named hostname** unless you add it. So a bare `HOST=` works out of the box for the prod server but needs `server.allowedHosts` configured (or a specific IP) under `--dev` — for network hosting, prefer the prod server (`mcp-inspector --web`) or bind a specific IP. + In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. ## HTTP proxy support diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index a397a92f5..38008a49b 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -11,18 +11,65 @@ export const BIND_ALL_INTERFACES_ENV = "DANGEROUSLY_BIND_ALL_INTERFACES"; /** - * Hostnames that bind *every* network interface rather than just loopback: - * `0.0.0.0` (IPv4 wildcard), `::` (IPv6 wildcard), and the empty string (Node's - * `listen()` treats "" as the unspecified/all-interfaces address). Binding any - * of these makes the backend — which can spawn local processes and reach MCP - * servers on behalf of the browser — reachable from the local network, which is - * the exact exposure DNS-rebinding attacks exploit. + * Exact spellings of the all-interfaces (unspecified) address. `0.0.0.0` (IPv4 + * wildcard), `::` and its expansions (IPv6 wildcard), the IPv4-mapped wildcard, + * and the empty string (Node's `listen()` treats "" as the unspecified address) + * all bind *every* interface. {@link isAllInterfacesHost} also catches the + * legacy numeric spellings the OS resolver still folds to `0.0.0.0`. */ -const ALL_INTERFACES_HOSTS = new Set(["0.0.0.0", "::", ""]); +const ALL_INTERFACES_LITERALS = new Set([ + "", + "0.0.0.0", + "::", + "0:0:0:0:0:0:0:0", + "::ffff:0.0.0.0", + "::ffff:0:0", +]); + +/** + * Parse one dotted-address part (or a bare address) the way the C `inet_aton` + * resolver does — decimal, `0`-prefixed octal, and `0x`-prefixed hex are all + * accepted. Returns `NaN` for anything non-numeric (e.g. a hostname label). + */ +function parseAddressPart(part: string): number { + if (/^0x[0-9a-f]+$/.test(part)) return parseInt(part, 16); + if (/^[0-9]+$/.test(part)) return parseInt(part, 10); + return NaN; +} + +/** + * True when `value` is an all-zero IPv4 address in any legacy spelling the OS + * still binds as the `0.0.0.0` wildcard: the bare integer `0`, `0x0`, dotted + * `0.0.0.0`, `000.000.000.000`, `0x0.0.0.0`, etc. (Node/`inet_aton` accept all + * of these.) Guards the near-miss bypasses of the literal set above. + */ +function isAllZeroIpv4(value: string): boolean { + if (value === "") return false; + const parts = value.split("."); + if (parts.length > 4) return false; + return parts.every((part) => parseAddressPart(part) === 0); +} /** True when `host` binds all interfaces rather than loopback only. */ export function isAllInterfacesHost(host: string): boolean { - return ALL_INTERFACES_HOSTS.has(host.trim().toLowerCase()); + const normalized = host + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); +} + +/** + * Wrap an IPv6 literal in brackets so it's a valid URL authority + * (`http://[::1]:6274`), and pass every other host (loopback names, IPv4, + * hostnames, already-bracketed IPv6) through unchanged. Shared by the origin + * allow-list, the startup banner, and the sandbox URL so all three format a + * bound IPv6 host the same way. + */ +export function formatHostForUrl(host: string): string { + const h = host.trim(); + if (h.startsWith("[") || !h.includes(":")) return h; + return `[${h}]`; } /** @@ -37,15 +84,17 @@ function isEnabled(value: string | undefined): boolean { /** * Resolve the bind hostname from `env` (default `process.env`), defaulting to - * `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty) 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. + * `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 so detection and + * the value handed to `listen()` agree. */ export function resolveBindHostname( env: NodeJS.ProcessEnv = process.env, ): string { - const host = env.HOST ?? "localhost"; + const host = (env.HOST ?? "localhost").trim(); if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) { throw new Error( `Refusing to bind HOST="${host}": this exposes the MCP Inspector to your ` + diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index e98ed9b8f..ab609bce0 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -232,12 +232,22 @@ export async function runWeb(argv: string[]): Promise { process.exit(1); } - const webConfig = buildWebServerConfig({ - initialMcpConfig, - mcpConfigPath, - writable, - initialServers, - }); + let webConfig; + 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 : "Invalid web server configuration."; + console.error(`Error: ${message}`); + process.exit(1); + } const webRoot = join(__dirname, ".."); const distRoot = join(webRoot, "dist"); if (!isDev) { diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 107e87f7b..d16ef5c4e 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -7,6 +7,7 @@ import { createServer, type Server } from "node:http"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { formatHostForUrl } from "./resolve-bind-host.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -56,8 +57,13 @@ export function createSandboxController( // 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. + // Loopback embedders only. `[::1]` is included alongside the IPv4/name forms + // because `localhost` resolves to either family and Node/Vite may bind the + // IPv6 loopback — so the browser can legitimately embed the proxy from + // `http://[::1]:PORT` (same reasoning as `defaultAllowedOrigins`). Omitting it + // CSP-blocks the sandbox iframe for exactly that case. const SANDBOX_PROXY_CSP = - "frame-ancestors http://127.0.0.1:* http://localhost:*"; + "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*"; let sandboxHtml: string; try { @@ -132,7 +138,7 @@ 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`; + sandboxUrl = `http://${formatHostForUrl(host)}:${actualPort}/sandbox`; settle({ port: actualPort, url: sandboxUrl }); }); }); diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 6d3890056..4a3fd5b7f 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -16,7 +16,7 @@ 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 { formatHostForUrl, resolveBindHostname } from "./resolve-bind-host.js"; // 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 @@ -164,7 +164,7 @@ export function printServerBanner( resolvedToken: string, sandboxUrl: string | undefined, ): string { - const baseUrl = `http://${config.hostname}:${actualPort}`; + const baseUrl = `http://${formatHostForUrl(config.hostname)}:${actualPort}`; const url = config.dangerouslyOmitAuth || !resolvedToken ? baseUrl @@ -215,7 +215,9 @@ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); * 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. For a non-loopback host (e.g. `0.0.0.0` or - * a real hostname) we return the single exact origin, unchanged. + * a real hostname) we return the single exact origin — lowercased and, for an + * IPv6 literal, bracketed, so it matches the `Origin` header the browser + * actually sends (browsers lowercase the host and bracket IPv6). */ export function defaultAllowedOrigins( hostname: string, @@ -228,7 +230,7 @@ export function defaultAllowedOrigins( `http://[::1]:${port}`, ]; } - return [`http://${hostname}:${port}`]; + return [`http://${formatHostForUrl(hostname.toLowerCase())}:${port}`]; } /** @@ -278,8 +280,9 @@ export function buildWebServerConfig( initialServers, storageDir: process.env.MCP_STORAGE_DIR, allowedOrigins: - process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? - defaultAllowedOrigins(hostname, port), + process.env.ALLOWED_ORIGINS?.split(",") + .map((o) => o.trim()) + .filter(Boolean) ?? defaultAllowedOrigins(hostname, port), sandboxPort, sandboxHost: hostname, logger, diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 0ecb078cc..8929c3675 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -1,22 +1,58 @@ import { describe, it, expect } from "vitest"; import { BIND_ALL_INTERFACES_ENV, + formatHostForUrl, isAllInterfacesHost, resolveBindHostname, } from "../../../../server/resolve-bind-host.js"; describe("isAllInterfacesHost", () => { - it.each(["0.0.0.0", "::", "", " 0.0.0.0 ", "::"])( - "flags the all-interfaces host %j", - (host) => { - expect(isAllInterfacesHost(host)).toBe(true); - }, - ); + it.each([ + "0.0.0.0", + "::", + "", + " 0.0.0.0 ", + " :: ", + "[::]", + "0:0:0:0:0:0:0:0", + "::ffff:0.0.0.0", + "::ffff:0:0", + // Legacy inet_aton spellings the OS still binds as 0.0.0.0. + "0", + "0x0", + "0x0.0.0.0", + "000.000.000.000", + ])("flags the all-interfaces host %j", (host) => { + expect(isAllInterfacesHost(host)).toBe(true); + }); - it.each(["localhost", "127.0.0.1", "::1", "[::1]", "example.com"])( - "does not flag the loopback/named host %j", + it.each([ + "localhost", + "127.0.0.1", + "::1", + "[::1]", + "example.com", + "192.168.1.50", + "1.0.0.0", + "0.0.0.1", + ])("does not flag the loopback/specific host %j", (host) => { + expect(isAllInterfacesHost(host)).toBe(false); + }); +}); + +describe("formatHostForUrl", () => { + it.each([ + ["::1", "[::1]"], + ["fe80::1", "[fe80::1]"], + [" ::1 ", "[::1]"], + ])("brackets the IPv6 literal %j", (host, expected) => { + expect(formatHostForUrl(host)).toBe(expected); + }); + + it.each(["localhost", "127.0.0.1", "192.168.1.50", "example.com", "[::1]"])( + "passes the non-IPv6 / already-bracketed host %j through", (host) => { - expect(isAllInterfacesHost(host)).toBe(false); + expect(formatHostForUrl(host)).toBe(host.trim()); }, ); }); @@ -30,7 +66,11 @@ describe("resolveBindHostname", () => { expect(resolveBindHostname({ HOST: "127.0.0.1" })).toBe("127.0.0.1"); }); - it.each(["0.0.0.0", "::", ""])( + it("trims the returned host so detection and the bind value agree", () => { + expect(resolveBindHostname({ HOST: " 127.0.0.1 " })).toBe("127.0.0.1"); + }); + + it.each(["0.0.0.0", "::", "", "0", "0x0.0.0.0", "::ffff:0.0.0.0", " 0 "])( "refuses the all-interfaces host %j without the opt-in", (host) => { expect(() => resolveBindHostname({ HOST: host })).toThrow( diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 7a9eff046..c79478f50 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -159,10 +159,10 @@ describe("buildWebServerConfigFromEnv", () => { expect(cfg.authToken).toBe("primary"); }); - it("parses ALLOWED_ORIGINS and filters empty entries", () => { - process.env.ALLOWED_ORIGINS = "http://a,,http://b"; + it("parses ALLOWED_ORIGINS, trimming entries and filtering empties", () => { + process.env.ALLOWED_ORIGINS = "http://a:1, , http://b:2 "; const cfg = buildWebServerConfigFromEnv(); - expect(cfg.allowedOrigins).toEqual(["http://a", "http://b"]); + expect(cfg.allowedOrigins).toEqual(["http://a:1", "http://b:2"]); }); it("resolves sandboxPort from MCP_SANDBOX_PORT", () => { @@ -250,10 +250,22 @@ describe("defaultAllowedOrigins", () => { expect(defaultAllowedOrigins("0.0.0.0", 8123)).toEqual([ "http://0.0.0.0:8123", ]); - expect(defaultAllowedOrigins("example.com", 80)).toEqual([ + expect(defaultAllowedOrigins("192.168.1.50", 6274)).toEqual([ + "http://192.168.1.50:6274", + ]); + }); + + it("lowercases a non-loopback hostname to match the browser's Origin", () => { + expect(defaultAllowedOrigins("Example.COM", 80)).toEqual([ "http://example.com:80", ]); }); + + it("brackets a non-loopback IPv6 literal so it's a valid origin", () => { + expect(defaultAllowedOrigins("fe80::1", 6274)).toEqual([ + "http://[fe80::1]:6274", + ]); + }); }); describe("buildWebServerConfig", () => { @@ -432,6 +444,13 @@ describe("printServerBanner", () => { expect(url).toBe("http://localhost:6274"); }); + it("brackets an IPv6 bind host in the printed URL", () => { + const cfg = baseConfig(); + cfg.hostname = "::1"; + const url = printServerBanner(cfg, 6274, "", undefined); + expect(url).toBe("http://[::1]:6274"); + }); + it("prints the sandbox URL when provided", () => { printServerBanner(baseConfig(), 6274, "tok", "http://sandbox:9999/sandbox"); expect( diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index d44d6c64d..58b3712a8 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -158,7 +158,14 @@ export default defineConfig(({ command }) => { // pointing at the wrong host and break browser fetches). server: { port: parseInt(process.env.CLIENT_PORT ?? "6274", 10), - host: resolveBindHostname(), + // Only enforce the bind-host guard when actually serving. `vite build` and + // the vitest projects evaluate this same config but never bind, so an + // ambient HOST=0.0.0.0 (e.g. exported in CI) must not fail them at config + // load with a message about binding. + host: + command === "serve" + ? resolveBindHostname() + : (process.env.HOST ?? "localhost"), strictPort: true, fs: { allow: [path.resolve(dirname, "../..")], From 944aeedc9cb2de5400ba57f6d789ea96af054080 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 16:44:10 -0400 Subject: [PATCH 04/31] =?UTF-8?q?review:=20round-2=20fixes=20=E2=80=94=20g?= =?UTF-8?q?ate=20plugin=20config=20eval,=20share=20host=20formatter,=20tes?= =?UTF-8?q?t=20CSP=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the re-review of #1796: - A1: gate honoMiddlewarePlugin on isDevServer, not just its `apply`/the server.host ternary. Its config arg buildWebServerConfigFromEnv() (→ resolveBindHostname) was evaluated eagerly, so an ambient HOST=0.0.0.0 still threw at config load for `vite build` and vitest. Comment corrected. - A2: assert the full sandbox frame-ancestors directive (incl. http://[::1]:*) so the IPv6-loopback CSP fix is regression-guarded, not prefix-matched. - A3: resolveBindHostname de-brackets its return (HOST=[::1] → ::1) so detection, listen(), and the origin list agree; formatHostForUrl re-adds. - A4: drop the dead `value === ""` branch in isAllZeroIpv4. - A5: `let webConfig: WebServerConfig` + match console.error("Error:", msg). - A6: move formatHostForUrl to core/node/hostUrl.ts (shared) and apply it at the two remaining unbracketed sites — server.ts EADDRINUSE message and the CLI deep-link (cli.ts buildHandoff, which reads HOST). resolve-bind-host re-exports it; new core test covers it (100%). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/src/cli.ts | 3 +- clients/web/server/resolve-bind-host.ts | 36 ++++++++----------- clients/web/server/run-web.ts | 9 +++-- clients/web/server/server.ts | 3 +- .../web/src/test/core/node/hostUrl.test.ts | 19 ++++++++++ .../server/resolve-bind-host.test.ts | 22 +++--------- .../server/sandbox-controller.test.ts | 7 +++- clients/web/vite.config.ts | 31 +++++++++------- core/node/hostUrl.ts | 12 +++++++ 9 files changed, 85 insertions(+), 57 deletions(-) create mode 100644 clients/web/src/test/core/node/hostUrl.test.ts create mode 100644 core/node/hostUrl.ts diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index f63815bca..13d3f8910 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -27,6 +27,7 @@ import { } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import { extractAppInfo } from "@inspector/core/mcp/apps.js"; +import { formatHostForUrl } from "@inspector/core/node/hostUrl.js"; import type { AppInfo } from "@inspector/core/mcp/apps.js"; import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js"; import { @@ -743,7 +744,7 @@ function buildHandoff( if (apiToken) params.set("autoConnect", apiToken); return { serverUrl: normalizedUrl, - deepLink: `http://${host}:${clientPort}/?${params.toString()}`, + deepLink: `http://${formatHostForUrl(host)}:${clientPort}/?${params.toString()}`, portForwardCmd: `coder port-forward --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`, oauthStatePath: statePath, apiToken: apiToken ?? null, diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 38008a49b..4260f2a1c 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -7,6 +7,10 @@ * dev server) so both bind points enforce the same policy. */ +// Re-exported so the two web bind points can keep importing the URL formatter +// from this module; the implementation is shared with the CLI via core/node. +export { formatHostForUrl } 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"; @@ -44,34 +48,22 @@ function parseAddressPart(part: string): number { * of these.) Guards the near-miss bypasses of the literal set above. */ function isAllZeroIpv4(value: string): boolean { - if (value === "") return false; const parts = value.split("."); if (parts.length > 4) return false; return parts.every((part) => parseAddressPart(part) === 0); } +/** Strip a single surrounding `[...]` pair from a bracketed IPv6 literal; other hosts pass through. */ +function stripIpv6Brackets(host: string): string { + return host.replace(/^\[(.+)\]$/, "$1"); +} + /** True when `host` binds all interfaces rather than loopback only. */ export function isAllInterfacesHost(host: string): boolean { - const normalized = host - .trim() - .toLowerCase() - .replace(/^\[|\]$/g, ""); + const normalized = stripIpv6Brackets(host.trim().toLowerCase()); return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); } -/** - * Wrap an IPv6 literal in brackets so it's a valid URL authority - * (`http://[::1]:6274`), and pass every other host (loopback names, IPv4, - * hostnames, already-bracketed IPv6) through unchanged. Shared by the origin - * allow-list, the startup banner, and the sandbox URL so all three format a - * bound IPv6 host the same way. - */ -export function formatHostForUrl(host: string): string { - const h = host.trim(); - if (h.startsWith("[") || !h.includes(":")) return h; - return `[${h}]`; -} - /** * An explicit, unambiguous opt-in. Unlike a bare `!!value` (which treats the * string `"false"` as truthy), only `"true"`/`"1"` (case-insensitive) enable @@ -88,8 +80,10 @@ function isEnabled(value: string | undefined): boolean { * 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 so detection and - * the value handed to `listen()` agree. + * 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, @@ -105,5 +99,5 @@ export function resolveBindHostname( `${BIND_ALL_INTERFACES_ENV}=true.`, ); } - return host; + return stripIpv6Brackets(host); } diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index ab609bce0..b1412d8c4 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -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,7 +235,7 @@ export async function runWeb(argv: string[]): Promise { process.exit(1); } - let webConfig; + let webConfig: WebServerConfig; try { webConfig = buildWebServerConfig({ initialMcpConfig, @@ -245,7 +248,7 @@ export async function runWeb(argv: string[]): Promise { // message rather than a raw stack trace from the launcher's top-level handler. const message = err instanceof Error ? err.message : "Invalid web server configuration."; - console.error(`Error: ${message}`); + console.error("Error:", message); process.exit(1); } const webRoot = join(__dirname, ".."); diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index 3bfde7bf1..6269fcda9 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -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"; @@ -131,7 +132,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 { diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts new file mode 100644 index 000000000..87a63496b --- /dev/null +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { formatHostForUrl } from "@inspector/core/node/hostUrl.js"; + +describe("formatHostForUrl", () => { + it.each([ + ["::1", "[::1]"], + ["fe80::1", "[fe80::1]"], + [" ::1 ", "[::1]"], + ])("brackets the IPv6 literal %j", (host, expected) => { + expect(formatHostForUrl(host)).toBe(expected); + }); + + it.each(["localhost", "127.0.0.1", "192.168.1.50", "example.com", "[::1]"])( + "passes the non-IPv6 / already-bracketed host %j through", + (host) => { + expect(formatHostForUrl(host)).toBe(host.trim()); + }, + ); +}); diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 8929c3675..9ca0a19b9 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { BIND_ALL_INTERFACES_ENV, - formatHostForUrl, isAllInterfacesHost, resolveBindHostname, } from "../../../../server/resolve-bind-host.js"; @@ -40,23 +39,6 @@ describe("isAllInterfacesHost", () => { }); }); -describe("formatHostForUrl", () => { - it.each([ - ["::1", "[::1]"], - ["fe80::1", "[fe80::1]"], - [" ::1 ", "[::1]"], - ])("brackets the IPv6 literal %j", (host, expected) => { - expect(formatHostForUrl(host)).toBe(expected); - }); - - it.each(["localhost", "127.0.0.1", "192.168.1.50", "example.com", "[::1]"])( - "passes the non-IPv6 / already-bracketed host %j through", - (host) => { - expect(formatHostForUrl(host)).toBe(host.trim()); - }, - ); -}); - describe("resolveBindHostname", () => { it("defaults to localhost when HOST is unset", () => { expect(resolveBindHostname({})).toBe("localhost"); @@ -70,6 +52,10 @@ describe("resolveBindHostname", () => { expect(resolveBindHostname({ HOST: " 127.0.0.1 " })).toBe("127.0.0.1"); }); + it("returns a bracketed IPv6 HOST bare so listen() can bind it", () => { + expect(resolveBindHostname({ HOST: "[::1]" })).toBe("::1"); + }); + it.each(["0.0.0.0", "::", "", "0", "0x0.0.0.0", "::ffff:0.0.0.0", " 0 "])( "refuses the all-interfaces host %j without the opt-in", (host) => { diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index c4f73c47f..c378396fa 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -85,7 +85,12 @@ describe("createSandboxController", () => { // container, so any default-src/connect-src here would intersect with and // override the per-app CSP baked into the inner document. const csp = res.headers.get("content-security-policy") ?? ""; - expect(csp).toContain("frame-ancestors http://127.0.0.1:*"); + // Assert the full directive (not a prefix) so the loopback family is + // regression-guarded — including the IPv6 loopback embedder, which is + // exactly the case a prefix match would let silently regress. + expect(csp).toContain( + "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*", + ); expect(csp).not.toContain("default-src"); expect(csp).not.toContain("connect-src"); const body = await res.text(); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 58b3712a8..3c5674bf7 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -95,9 +95,14 @@ function browserExternalizedBuiltinGate(): Plugin { export default defineConfig(({ command }) => { const isDevServer = command === "serve" && !process.env.VITEST; return { - // `honoMiddlewarePlugin` is gated by `apply: 'serve'` so it only attaches - // during `vite dev` / `vite preview` — vitest projects share this config - // but never invoke `configureServer`, so the plugin stays inert there. + // `honoMiddlewarePlugin` only attaches during `vite dev` / `vite preview`. + // It's included conditionally on `isDevServer` (not merely `apply: 'serve'`) + // because its config *argument* — `buildWebServerConfigFromEnv()`, which + // calls `resolveBindHostname()` — is evaluated eagerly when this array is + // built. Left unconditional, an ambient `HOST=0.0.0.0` would make the guard + // throw at config load for `vite build` and every vitest project too, not + // just when serving. Gating the whole plugin also skips that wasted config + // build for non-serve commands. // // The plugin statically imports the node-only dev backend // (`core/mcp/remote/node/server.ts`), so Vite's config bundler (Rolldown) @@ -114,7 +119,9 @@ export default defineConfig(({ command }) => { // reaches the browser bundle (#1769) — see its definition above. plugins: [ react(), - honoMiddlewarePlugin(buildWebServerConfigFromEnv()), + ...(isDevServer + ? [honoMiddlewarePlugin(buildWebServerConfigFromEnv())] + : []), browserExternalizedBuiltinGate(), ], // Shared optimizeDeps exclusions so node-only packages @@ -158,14 +165,14 @@ export default defineConfig(({ command }) => { // pointing at the wrong host and break browser fetches). server: { port: parseInt(process.env.CLIENT_PORT ?? "6274", 10), - // Only enforce the bind-host guard when actually serving. `vite build` and - // the vitest projects evaluate this same config but never bind, so an - // ambient HOST=0.0.0.0 (e.g. exported in CI) must not fail them at config - // load with a message about binding. - host: - command === "serve" - ? resolveBindHostname() - : (process.env.HOST ?? "localhost"), + // Only enforce the bind-host guard when actually serving (`isDevServer` + // also excludes vitest, whose `command` is `serve`). `vite build` and the + // vitest projects evaluate this same config but never bind, so an ambient + // HOST=0.0.0.0 (e.g. exported in CI) must not fail them at config load + // with a message about binding. + host: isDevServer + ? resolveBindHostname() + : (process.env.HOST ?? "localhost"), strictPort: true, fs: { allow: [path.resolve(dirname, "../..")], diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts new file mode 100644 index 000000000..c9e15d757 --- /dev/null +++ b/core/node/hostUrl.ts @@ -0,0 +1,12 @@ +/** + * Format a bind host for use inside a URL authority. An IPv6 literal must be + * bracketed (`http://[::1]:6274`); every other host — loopback names, IPv4, + * hostnames, already-bracketed IPv6 — passes through unchanged. Shared by the + * web origin allow-list, the startup banner, the sandbox URL, and the CLI + * deep-link so a bound IPv6 host is formatted the same way everywhere. + */ +export function formatHostForUrl(host: string): string { + const h = host.trim(); + if (h.startsWith("[") || !h.includes(":")) return h; + return `[${h}]`; +} From d8dbf93a1070c1164631e5f44c214890f9518920 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 16:57:05 -0400 Subject: [PATCH 05/31] =?UTF-8?q?review:=20round-3=20fixes=20=E2=80=94=20c?= =?UTF-8?q?lose=20IPv6=20wildcard=20bypass,=20default-port=20origin,=20ded?= =?UTF-8?q?upe=20formatter=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-3 review of #1796: - C1: canonicalize IPv6 via the WHATWG serializer before the wildcard-set lookup, so HOST=::0 / 0::0 / ::0.0.0.0 / 0:0::0 / fully-expanded zeros are all refused (the IPv6 mirror of the HOST=0 hole). Literal set shrinks to "" | 0.0.0.0 | :: | ::ffff:0:0. - C2: defaultAllowedOrigins omits the port when it's the http default (80) — browsers drop :80 from Origin and the guard is an exact match, so :80 could never match. - C3: swap the two pre-existing formatHostForUrl copies in core/auth/node (oauth-callback-server, runner-oauth-callback) for the shared core/node one. - C4: derive the sandbox frame-ancestors from allowedOrigins (new sandboxFrameAncestors) so the MCP Apps iframe isn't CSP-blocked under network hosting; loopback-wildcard fallback when the list is empty. - C5: add the parts.length>4 negative case (0.0.0.0.0); drop the resolve-bind-host re-export and import formatHostForUrl from core/node directly at all sites. - tests for all of the above. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/server/resolve-bind-host.ts | 41 +++++++++++-------- clients/web/server/sandbox-controller.ts | 40 +++++++++++++----- clients/web/server/server.ts | 1 + clients/web/server/vite-hono-plugin.ts | 1 + clients/web/server/web-server-config.ts | 19 ++++++--- .../server/resolve-bind-host.test.ts | 8 ++++ .../server/sandbox-controller.test.ts | 23 +++++++++++ .../server/web-server-config.test.ts | 17 +++++++- core/auth/node/oauth-callback-server.ts | 5 +-- core/auth/node/runner-oauth-callback.ts | 9 ++-- 10 files changed, 120 insertions(+), 44 deletions(-) diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 4260f2a1c..788cd68d1 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -7,28 +7,33 @@ * dev server) so both bind points enforce the same policy. */ -// Re-exported so the two web bind points can keep importing the URL formatter -// from this module; the implementation is shared with the CLI via core/node. -export { formatHostForUrl } from "../../../core/node/hostUrl.ts"; +import { isIPv6 } from "node:net"; /** Env var that opts into binding all network interfaces (see {@link resolveBindHostname}). */ export const BIND_ALL_INTERFACES_ENV = "DANGEROUSLY_BIND_ALL_INTERFACES"; /** - * Exact spellings of the all-interfaces (unspecified) address. `0.0.0.0` (IPv4 - * wildcard), `::` and its expansions (IPv6 wildcard), the IPv4-mapped wildcard, - * and the empty string (Node's `listen()` treats "" as the unspecified address) - * all bind *every* interface. {@link isAllInterfacesHost} also catches the - * legacy numeric spellings the OS resolver still folds to `0.0.0.0`. + * Canonical spellings of the all-interfaces (unspecified) address. Every IPv6 + * wildcard spelling (`::`, `::0`, `0:0:…:0`, `::0.0.0.0`, …) canonicalizes to + * `::` and the IPv4-mapped wildcard to `::ffff:0:0` (see {@link canonicalizeIpv6}), + * so those two entries cover the whole IPv6 family; `0.0.0.0` is the IPv4 + * wildcard and `""` is Node's `listen()` unspecified address. All bind *every* + * interface. {@link isAllInterfacesHost} additionally folds the legacy IPv4 + * spellings the OS resolver still binds as `0.0.0.0`. */ -const ALL_INTERFACES_LITERALS = new Set([ - "", - "0.0.0.0", - "::", - "0:0:0:0:0:0:0:0", - "::ffff:0.0.0.0", - "::ffff:0:0", -]); +const ALL_INTERFACES_LITERALS = new Set(["", "0.0.0.0", "::", "::ffff:0:0"]); + +/** + * Canonicalize an IPv6 literal via the WHATWG URL serializer, which compresses + * zero-runs — so `::0`, `0::0`, `::0.0.0.0`, and the fully-expanded + * `0000:…:0000` all collapse to `::`, and `::ffff:0.0.0.0` → `::ffff:0:0`. This + * is what lets a small literal set catch every all-zero IPv6 spelling rather + * than only the handful written out. Non-IPv6 input passes through unchanged. + */ +function canonicalizeIpv6(value: string): string { + if (!isIPv6(value)) return value; + return new URL(`http://[${value}]`).hostname.slice(1, -1); +} /** * Parse one dotted-address part (or a bare address) the way the C `inet_aton` @@ -60,7 +65,9 @@ function stripIpv6Brackets(host: string): string { /** True when `host` binds all interfaces rather than loopback only. */ export function isAllInterfacesHost(host: string): boolean { - const normalized = stripIpv6Brackets(host.trim().toLowerCase()); + const normalized = canonicalizeIpv6( + stripIpv6Brackets(host.trim().toLowerCase()), + ); return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); } diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index d16ef5c4e..88825f7f4 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -7,7 +7,7 @@ import { createServer, type Server } from "node:http"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { formatHostForUrl } from "./resolve-bind-host.js"; +import { formatHostForUrl } from "../../../core/node/hostUrl.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -16,6 +16,29 @@ 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). When + * empty or omitted, falls back to loopback-only. + */ + allowedOrigins?: string[]; +} + +/** + * 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. Falls + * back to the loopback family (any port) when the list is empty — the "origin + * check disabled" case — so the sandbox still loads locally. + */ +export function sandboxFrameAncestors(allowedOrigins?: string[]): string { + const sources = + allowedOrigins && allowedOrigins.length > 0 + ? allowedOrigins + : ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"]; + return `frame-ancestors ${sources.join(" ")}`; } export interface SandboxController { @@ -44,7 +67,7 @@ export function resolveSandboxPort(): number { 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; @@ -55,15 +78,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. - // Loopback embedders only. `[::1]` is included alongside the IPv4/name forms - // because `localhost` resolves to either family and Node/Vite may bind the - // IPv6 loopback — so the browser can legitimately embed the proxy from - // `http://[::1]:PORT` (same reasoning as `defaultAllowedOrigins`). Omitting it - // CSP-blocks the sandbox iframe for exactly that case. - const SANDBOX_PROXY_CSP = - "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*"; + // 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 { diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index 6269fcda9..9f6ff1347 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -40,6 +40,7 @@ export async function startHonoServer( const sandboxController = createSandboxController({ port: config.sandboxPort, host: config.sandboxHost, + allowedOrigins: config.allowedOrigins, }); await sandboxController.start(); diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 67346b428..1331930ce 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -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(); diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 4a3fd5b7f..ce46cb89d 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -16,7 +16,8 @@ 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 { formatHostForUrl, resolveBindHostname } from "./resolve-bind-host.js"; +import { resolveBindHostname } from "./resolve-bind-host.js"; +import { formatHostForUrl } 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 @@ -218,19 +219,27 @@ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); * a real hostname) we return the single exact origin — lowercased and, for an * IPv6 literal, bracketed, so it matches the `Origin` header the browser * actually sends (browsers lowercase the host and bracket IPv6). + * + * 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}`; +} + export function defaultAllowedOrigins( hostname: string, port: number, ): string[] { if (LOOPBACK_HOSTNAMES.has(hostname.toLowerCase())) { return [ - `http://localhost:${port}`, - `http://127.0.0.1:${port}`, - `http://[::1]:${port}`, + httpOrigin("localhost", port), + httpOrigin("127.0.0.1", port), + httpOrigin("[::1]", port), ]; } - return [`http://${formatHostForUrl(hostname.toLowerCase())}:${port}`]; + return [httpOrigin(formatHostForUrl(hostname.toLowerCase()), port)]; } /** diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 9ca0a19b9..a6ca6b3cf 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -16,6 +16,12 @@ describe("isAllInterfacesHost", () => { "0:0:0:0:0:0:0:0", "::ffff:0.0.0.0", "::ffff:0:0", + // IPv6 wildcard spellings that canonicalize to `::`. + "::0", + "0::0", + "::0.0.0.0", + "0:0::0", + "0000:0000:0000:0000:0000:0000:0000:0000", // Legacy inet_aton spellings the OS still binds as 0.0.0.0. "0", "0x0", @@ -34,6 +40,8 @@ describe("isAllInterfacesHost", () => { "192.168.1.50", "1.0.0.0", "0.0.0.1", + "::ffff:0", // canonicalizes to ::ffff:0, a distinct address — not the wildcard + "0.0.0.0.0", // 5 octets — not a valid IPv4, must not be flagged (parts.length > 4) ])("does not flag the loopback/specific host %j", (host) => { expect(isAllInterfacesHost(host)).toBe(false); }); diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index c378396fa..bcf2ac0ba 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -3,8 +3,31 @@ import { createServer, type Server } from "node:http"; import { createSandboxController, resolveSandboxPort, + sandboxFrameAncestors, } from "../../../../server/sandbox-controller.js"; +describe("sandboxFrameAncestors", () => { + it("derives the directive from the provided allow-list", () => { + expect( + sandboxFrameAncestors([ + "http://192.168.1.50:6274", + "https://inspector.example.com", + ]), + ).toBe( + "frame-ancestors http://192.168.1.50:6274 https://inspector.example.com", + ); + }); + + it.each([[undefined], [[]]])( + "falls back to the loopback family when the list is %j", + (origins) => { + expect(sandboxFrameAncestors(origins as string[] | undefined)).toBe( + "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*", + ); + }, + ); +}); + describe("resolveSandboxPort", () => { let envSnapshot: { mcp?: string; server?: string }; diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index c79478f50..ef9c0f576 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -256,8 +256,21 @@ describe("defaultAllowedOrigins", () => { }); it("lowercases a non-loopback hostname to match the browser's Origin", () => { - expect(defaultAllowedOrigins("Example.COM", 80)).toEqual([ - "http://example.com:80", + expect(defaultAllowedOrigins("Example.COM", 6274)).toEqual([ + "http://example.com:6274", + ]); + }); + + it("omits the port from the origin when it's the http default (80)", () => { + // Browsers drop :80 from the Origin header, and the guard is an exact + // match, so an origin with :80 could never match a real request. + expect(defaultAllowedOrigins("192.168.1.50", 80)).toEqual([ + "http://192.168.1.50", + ]); + expect(defaultAllowedOrigins("localhost", 80)).toEqual([ + "http://localhost", + "http://127.0.0.1", + "http://[::1]", ]); }); diff --git a/core/auth/node/oauth-callback-server.ts b/core/auth/node/oauth-callback-server.ts index 37a0cce6a..fa17b59b3 100644 --- a/core/auth/node/oauth-callback-server.ts +++ b/core/auth/node/oauth-callback-server.ts @@ -1,4 +1,5 @@ import { createServer, type Server } from "node:http"; +import { formatHostForUrl } from "../../node/hostUrl.js"; import { parseOAuthCallbackParams } from "../utils.js"; import { generateOAuthErrorDescription } from "../utils.js"; @@ -220,7 +221,5 @@ export function createOAuthCallbackServer(): OAuthCallbackServer { } function buildRedirectUrl(host: string, port: number, path: string): string { - const needsBrackets = host.includes(":") && !host.startsWith("["); - const formattedHost = needsBrackets ? `[${host}]` : host; - return `http://${formattedHost}:${port}${path}`; + return `http://${formatHostForUrl(host)}:${port}${path}`; } diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts index e8a374a3f..d52489606 100644 --- a/core/auth/node/runner-oauth-callback.ts +++ b/core/auth/node/runner-oauth-callback.ts @@ -10,6 +10,8 @@ * unsupported; override via `--callback-url` / `MCP_OAUTH_CALLBACK_URL`. */ +import { formatHostForUrl } from "../../node/hostUrl.js"; + export const RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME = "127.0.0.1"; /** Default loopback port for TUI/CLI OAuth callback (6276 ≈ T9 "MCPO", MCP OAuth). */ export const RUNNER_OAUTH_CALLBACK_DEFAULT_PORT = 6276; @@ -87,10 +89,5 @@ export function parseRunnerOAuthCallbackUrl( export function formatRunnerOAuthRedirectUrl( config: RunnerOAuthCallbackConfig, ): string { - const needsBrackets = - config.hostname.includes(":") && !config.hostname.startsWith("["); - const formattedHost = needsBrackets - ? `[${config.hostname}]` - : config.hostname; - return `http://${formattedHost}:${config.port}${config.pathname}`; + return `http://${formatHostForUrl(config.hostname)}:${config.port}${config.pathname}`; } From ed1f993057b5b164517b924314054826fdbe9dad Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 17:10:16 -0400 Subject: [PATCH 06/31] =?UTF-8?q?review:=20round-4=20fixes=20=E2=80=94=20z?= =?UTF-8?q?one-index=20guard=20crash,=20CSP=20header=20hardening,=20docs?= =?UTF-8?q?=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-4 review of #1796: - E1: strip the IPv6 zone index (%eth0) before canonicalizing. net.isIPv6 accepts a zone id but new URL() rejects it (even %25-encoded), so a zone-scoped HOST previously threw an opaque TypeError out of the guard — breaking a legitimate link-local bind (fe80::1%eth0) and losing the actionable message on ::%eth0 (a real wildcard). Now ::%eth0 → :: (flagged), fe80::1%eth0 → fe80::1 (not flagged) and the zone is kept on the returned value for listen(). - E2: filter allowedOrigins to well-formed scheme://host[:port] sources before interpolating them into the sandbox CSP header, so a newline (ERR_INVALID_CHAR → dead sandbox page) or ';' (injected CSP directives) in ALLOWED_ORIGINS can't corrupt the header; falls back to loopback when nothing valid remains. - E4a: printServerBanner uses httpOrigin too, so banner and origin list agree on the default-port (:80) form. - E4b/E4c/E3: doc clarifications — isAllZeroIpv4's >4 guard rationale + short inet_aton forms; README notes the wildcard covers every spelling, and an MCP Apps caveat (sandbox runs on a separate port that must be reachable off loopback). - tests for the zone-index and CSP-filtering paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 4 ++- clients/web/server/resolve-bind-host.ts | 15 ++++++++-- clients/web/server/sandbox-controller.ts | 30 ++++++++++++++----- clients/web/server/web-server-config.ts | 4 ++- .../server/resolve-bind-host.test.ts | 13 ++++++++ .../server/sandbox-controller.test.ts | 19 ++++++++++++ 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index af2994448..a387e1858 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -161,7 +161,7 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer ## Host binding & the origin allow-list -Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, or empty) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. +Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override. @@ -175,6 +175,8 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts but **rejects a named hostname** unless you add it. So a bare `HOST=` works out of the box for the prod server but needs `server.allowedHosts` configured (or a specific IP) under `--dev` — for network hosting, prefer the prod server (`mcp-inspector --web`) or bind a specific IP. +**MCP Apps caveat.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default) and its URL is built from the bind host. For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). Under the `0.0.0.0` wildcard the sandbox URL becomes `http://0.0.0.0:`, which the client can't reach; bind a specific address if you need MCP Apps over the network. + In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. ## HTTP proxy support diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 788cd68d1..b5f8a7d22 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -29,10 +29,17 @@ const ALL_INTERFACES_LITERALS = new Set(["", "0.0.0.0", "::", "::ffff:0:0"]); * `0000:…:0000` all collapse to `::`, and `::ffff:0.0.0.0` → `::ffff:0:0`. This * is what lets a small literal set catch every all-zero IPv6 spelling rather * than only the handful written out. Non-IPv6 input passes through unchanged. + * + * The zone index (`%eth0`) is stripped first: `net.isIPv6` accepts it but + * `new URL()` rejects a zone id outright (even `%25`-encoded), so passing it + * through would throw. The zone is irrelevant to *which* address this is, so + * dropping it is correct for detection — `::%eth0` still canonicalizes to `::`. + * (The caller keeps the zone on the value it returns for `listen()`.) */ function canonicalizeIpv6(value: string): string { if (!isIPv6(value)) return value; - return new URL(`http://[${value}]`).hostname.slice(1, -1); + const [address] = value.split("%"); + return new URL(`http://[${address}]`).hostname.slice(1, -1); } /** @@ -49,8 +56,10 @@ function parseAddressPart(part: string): number { /** * True when `value` is an all-zero IPv4 address in any legacy spelling the OS * still binds as the `0.0.0.0` wildcard: the bare integer `0`, `0x0`, dotted - * `0.0.0.0`, `000.000.000.000`, `0x0.0.0.0`, etc. (Node/`inet_aton` accept all - * of these.) Guards the near-miss bypasses of the literal set above. + * `0.0.0.0`, `000.000.000.000`, `0x0.0.0.0`, and the short forms `0.0` / `0.0.0` + * (Node/`inet_aton` accept 1–4 parts). Guards the near-miss bypasses of the + * literal set above. The `> 4` reject is intentional — 1–3 parts are valid + * `inet_aton` spellings, so this is deliberately NOT `parts.length === 4`. */ function isAllZeroIpv4(value: string): boolean { const parts = value.split("."); diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 88825f7f4..fb7ce73df 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -25,19 +25,35 @@ export interface SandboxControllerOptions { allowedOrigins?: string[]; } +const LOOPBACK_FRAME_ANCESTORS = [ + "http://127.0.0.1:*", + "http://localhost:*", + "http://[::1]:*", +]; + +/** + * A well-formed CSP host-source: `scheme://host[:port]` with no whitespace or + * CSP metacharacters. `allowedOrigins` is user-controlled (the `ALLOWED_ORIGINS` + * env var), and its values are interpolated into the `Content-Security-Policy` + * response header — so a newline would make `writeHead` throw `ERR_INVALID_CHAR` + * (killing the sandbox page) and a `;` would inject extra CSP directives. Only + * values matching this shape are allowed through {@link sandboxFrameAncestors}. + */ +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. Falls - * back to the loopback family (any port) when the list is empty — the "origin - * check disabled" case — so the sandbox still loads locally. + * 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 (empty list, or the "origin check disabled" case), 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 sources = - allowedOrigins && allowedOrigins.length > 0 - ? allowedOrigins - : ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"]; + const valid = (allowedOrigins ?? []).filter((o) => CSP_HOST_SOURCE.test(o)); + const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS; return `frame-ancestors ${sources.join(" ")}`; } diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index ce46cb89d..773d2d073 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -165,7 +165,9 @@ export function printServerBanner( resolvedToken: string, sandboxUrl: string | undefined, ): string { - const baseUrl = `http://${formatHostForUrl(config.hostname)}:${actualPort}`; + // Uses httpOrigin so the banner and the origin allow-list agree on the + // default-port form (both drop :80). + const baseUrl = httpOrigin(formatHostForUrl(config.hostname), actualPort); const url = config.dangerouslyOmitAuth || !resolvedToken ? baseUrl diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index a6ca6b3cf..323ba8f95 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -22,11 +22,16 @@ describe("isAllInterfacesHost", () => { "::0.0.0.0", "0:0::0", "0000:0000:0000:0000:0000:0000:0000:0000", + // Zone-scoped wildcard: net.isIPv6 accepts the %zone, new URL() rejects it, + // so the zone must be stripped before canonicalizing (else it throws). + "::%eth0", // Legacy inet_aton spellings the OS still binds as 0.0.0.0. "0", "0x0", "0x0.0.0.0", "000.000.000.000", + "0.0", // short inet_aton form (1–3 parts) still binds the wildcard + "0.0.0", ])("flags the all-interfaces host %j", (host) => { expect(isAllInterfacesHost(host)).toBe(true); }); @@ -42,6 +47,8 @@ describe("isAllInterfacesHost", () => { "0.0.0.1", "::ffff:0", // canonicalizes to ::ffff:0, a distinct address — not the wildcard "0.0.0.0.0", // 5 octets — not a valid IPv4, must not be flagged (parts.length > 4) + "fe80::1%eth0", // a zone-scoped link-local — a real bind host, not the wildcard + "::1%lo0", // zone-scoped loopback — must not be flagged and must not throw ])("does not flag the loopback/specific host %j", (host) => { expect(isAllInterfacesHost(host)).toBe(false); }); @@ -64,6 +71,12 @@ describe("resolveBindHostname", () => { expect(resolveBindHostname({ HOST: "[::1]" })).toBe("::1"); }); + it("keeps the zone index on a link-local HOST for listen()", () => { + // The guard must not throw on a zone-scoped host, and must return it with + // the zone intact (listen() needs the zone to pick the interface). + expect(resolveBindHostname({ HOST: "fe80::1%eth0" })).toBe("fe80::1%eth0"); + }); + it.each(["0.0.0.0", "::", "", "0", "0x0.0.0.0", "::ffff:0.0.0.0", " 0 "])( "refuses the all-interfaces host %j without the opt-in", (host) => { diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index bcf2ac0ba..510526f9d 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -26,6 +26,25 @@ describe("sandboxFrameAncestors", () => { ); }, ); + + it("drops malformed entries that could inject CSP directives or crash writeHead", () => { + // A newline would make writeHead throw ERR_INVALID_CHAR; a ';' would inject + // extra directives. Only the well-formed origin survives. + expect( + sandboxFrameAncestors([ + "http://good.example:6274", + "http://a:1; sandbox", + "http://b:2\nX-Evil: 1", + "not a url", + ]), + ).toBe("frame-ancestors http://good.example:6274"); + }); + + it("falls back to loopback when every entry is malformed", () => { + expect(sandboxFrameAncestors(["http://a:1; sandbox", "garbage"])).toBe( + "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*", + ); + }); }); describe("resolveSandboxPort", () => { From e571afeefd0d595981823040a562f6a931cb7b79 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 17:25:27 -0400 Subject: [PATCH 07/31] =?UTF-8?q?review:=20round-5=20fixes=20=E2=80=94=20w?= =?UTF-8?q?ildcard=20allow-list,=20IPv6-in-CSP=20reality,=20URL=20builder?= =?UTF-8?q?=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-5 review of #1796: - F1: defaultAllowedOrigins returns the loopback trio for an all-interfaces bind (0.0.0.0 / ::) — a wildcard bind serves loopback, so `docker run -p` browsed at http://localhost:PORT now connects out of the box instead of 403ing against the unmatchable http://0.0.0.0:PORT. README/Docker bullet reconciled (non-loopback access still needs ALLOWED_ORIGINS). - F2: VERIFIED in headless Chromium that a bracketed IPv6 literal is NOT a valid CSP host-source — `frame-ancestors http://[::1]:*` degrades to 'none' and blocks the frame. So the round-1 [::1] CSP entry never worked: dropped it from the loopback fallback and from the derived directive (CSP_HOST_SOURCE now rejects brackets), and documented that MCP Apps requires browsing at a name/IPv4, not a bare [::1]. - F3/F4b: formatHostForUrl brackets only real IPv6 (net.isIPv6, not "has a colon") and drops the zone id (a URL host can't carry one), so a mistyped host:port and a zone-scoped bind host no longer produce invalid URLs. - F4a: integration test asserts the CSP the product actually serves (controller constructed WITH allowedOrigins), not just the fallback. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 4 +- clients/web/server/sandbox-controller.ts | 30 +++++++----- clients/web/server/web-server-config.ts | 28 ++++++++--- .../web/src/test/core/node/hostUrl.test.ts | 7 +++ .../server/sandbox-controller.test.ts | 49 ++++++++++++++----- .../server/web-server-config.test.ts | 27 ++++++++-- core/node/hostUrl.ts | 12 ++++- 7 files changed, 118 insertions(+), 39 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index a387e1858..4908b400f 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -171,11 +171,11 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe - **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. -- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`) you **must** also set `ALLOWED_ORIGINS`: the default becomes `http://0.0.0.0:PORT`, which no browser ever sends as its `Origin`, so connects would 403. List the origin(s) clients actually use: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. +- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it 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: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts but **rejects a named hostname** unless you add it. So a bare `HOST=` works out of the box for the prod server but needs `server.allowedHosts` configured (or a specific IP) under `--dev` — for network hosting, prefer the prod server (`mcp-inspector --web`) or bind a specific IP. -**MCP Apps caveat.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default) and its URL is built from the bind host. For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). Under the `0.0.0.0` wildcard the sandbox URL becomes `http://0.0.0.0:`, which the client can't reach; bind a specific address if you need MCP Apps over the network. +**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default) and its URL is built from the bind host. For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). Under the `0.0.0.0` wildcard the sandbox URL becomes `http://0.0.0.0:`, which the client can't reach; bind a specific address if you need MCP Apps over the network. Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index fb7ce73df..8820328aa 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -25,21 +25,27 @@ export interface SandboxControllerOptions { allowedOrigins?: string[]; } -const LOOPBACK_FRAME_ANCESTORS = [ - "http://127.0.0.1:*", - "http://localhost:*", - "http://[::1]:*", -]; +// 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 or - * CSP metacharacters. `allowedOrigins` is user-controlled (the `ALLOWED_ORIGINS` - * env var), and its values are interpolated into the `Content-Security-Policy` - * response header — so a newline would make `writeHead` throw `ERR_INVALID_CHAR` - * (killing the sandbox page) and a `;` would inject extra CSP directives. Only - * values matching this shape are allowed through {@link sandboxFrameAncestors}. + * A well-formed CSP host-source: `scheme://host[:port]` with no whitespace, CSP + * metacharacters, or brackets. `allowedOrigins` is user-controlled (the + * `ALLOWED_ORIGINS` env var) and its values are interpolated into the + * `Content-Security-Policy` response header — so a newline would make + * `writeHead` throw `ERR_INVALID_CHAR` (killing the sandbox page) and a `;` + * would inject extra CSP directives. Brackets are excluded too: a bracketed IPv6 + * literal (`http://[::1]:PORT`) is not a valid CSP host-source, so emitting it + * is at best dead weight and — as the only source — would block the frame. + * Only values matching this shape survive into {@link sandboxFrameAncestors}. */ -const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"]+$/i; +const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]]+$/i; /** * The proxy's `frame-ancestors` sources. The embedder is the inspector app diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 773d2d073..2447fdf33 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -16,7 +16,10 @@ 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 { + isAllInterfacesHost, + resolveBindHostname, +} from "./resolve-bind-host.js"; import { formatHostForUrl } from "../../../core/node/hostUrl.ts"; // The single-source Inspector version (root package.json), read once at load. @@ -217,10 +220,20 @@ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); * 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. For a non-loopback host (e.g. `0.0.0.0` or - * a real hostname) we return the single exact origin — lowercased and, for an - * IPv6 literal, bracketed, so it matches the `Origin` header the browser - * actually sends (browsers lowercase the host and bracket IPv6). + * `localhost` the banner advertised. + * + * An **all-interfaces** bind (`0.0.0.0` / `::`, the opt-in path the Docker image + * uses) also serves loopback, so it gets the same loopback trio: a browser + * reaching the container via `docker run -p 6274:6274` navigates to + * `http://localhost:6274` and sends that as its `Origin`. The bind host itself + * (`0.0.0.0`) is never sent as an `Origin`, so a `http://0.0.0.0:PORT` entry + * would only ever be dead weight. (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 — lowercased and, for an IPv6 literal, bracketed, so it matches + * the `Origin` header the browser actually sends. * * 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 @@ -234,14 +247,15 @@ export function defaultAllowedOrigins( hostname: string, port: number, ): string[] { - if (LOOPBACK_HOSTNAMES.has(hostname.toLowerCase())) { + const h = hostname.toLowerCase(); + if (LOOPBACK_HOSTNAMES.has(h) || isAllInterfacesHost(hostname)) { return [ httpOrigin("localhost", port), httpOrigin("127.0.0.1", port), httpOrigin("[::1]", port), ]; } - return [httpOrigin(formatHostForUrl(hostname.toLowerCase()), port)]; + return [httpOrigin(formatHostForUrl(h), port)]; } /** diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index 87a63496b..70bb55db6 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -6,6 +6,8 @@ describe("formatHostForUrl", () => { ["::1", "[::1]"], ["fe80::1", "[fe80::1]"], [" ::1 ", "[::1]"], + ["fe80::1%eth0", "[fe80::1]"], // zone id dropped — a URL host can't carry one + ["::1%lo0", "[::1]"], ])("brackets the IPv6 literal %j", (host, expected) => { expect(formatHostForUrl(host)).toBe(expected); }); @@ -16,4 +18,9 @@ describe("formatHostForUrl", () => { expect(formatHostForUrl(host)).toBe(host.trim()); }, ); + + it("does not bracket a non-IPv6 value that merely contains a colon", () => { + // A mistyped host:port must not be wrapped as [host:port]. + expect(formatHostForUrl("localhost:6274")).toBe("localhost:6274"); + }); }); diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 510526f9d..443075975 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -21,29 +21,33 @@ describe("sandboxFrameAncestors", () => { it.each([[undefined], [[]]])( "falls back to the loopback family when the list is %j", (origins) => { + // No `[::1]` source — a bracketed IPv6 literal is not a valid CSP + // host-source (see sandbox-controller.ts). expect(sandboxFrameAncestors(origins as string[] | undefined)).toBe( - "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*", + "frame-ancestors http://127.0.0.1:* http://localhost:*", ); }, ); - it("drops malformed entries that could inject CSP directives or crash writeHead", () => { + it("drops malformed and IPv6-literal entries that can't be valid CSP sources", () => { // A newline would make writeHead throw ERR_INVALID_CHAR; a ';' would inject - // extra directives. Only the well-formed origin survives. + // extra directives; a bracketed IPv6 literal isn't a valid CSP host-source. + // Only the well-formed origin survives. expect( sandboxFrameAncestors([ "http://good.example:6274", "http://a:1; sandbox", "http://b:2\nX-Evil: 1", + "http://[::1]:6274", "not a url", ]), ).toBe("frame-ancestors http://good.example:6274"); }); - it("falls back to loopback when every entry is malformed", () => { - expect(sandboxFrameAncestors(["http://a:1; sandbox", "garbage"])).toBe( - "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*", - ); + it("falls back to loopback when every entry is malformed or IPv6-literal", () => { + expect( + sandboxFrameAncestors(["http://a:1; sandbox", "http://[::1]:6274"]), + ).toBe("frame-ancestors http://127.0.0.1:* http://localhost:*"); }); }); @@ -127,11 +131,11 @@ describe("createSandboxController", () => { // container, so any default-src/connect-src here would intersect with and // override the per-app CSP baked into the inner document. const csp = res.headers.get("content-security-policy") ?? ""; - // Assert the full directive (not a prefix) so the loopback family is - // regression-guarded — including the IPv6 loopback embedder, which is - // exactly the case a prefix match would let silently regress. + // Assert the full directive (not a prefix) so the fallback is + // regression-guarded. No `[::1]` — a bracketed IPv6 literal is not a + // valid CSP host-source. expect(csp).toContain( - "frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*", + "frame-ancestors http://127.0.0.1:* http://localhost:*", ); expect(csp).not.toContain("default-src"); expect(csp).not.toContain("connect-src"); @@ -144,6 +148,29 @@ describe("createSandboxController", () => { } }); + it("serves a CSP derived from allowedOrigins (the shipped default path)", async () => { + // Both real callers always pass allowedOrigins, so the header the product + // actually serves is the derived exact-origin directive, not the fallback. + const controller = createSandboxController({ + port: 0, + allowedOrigins: [ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", // dropped — not a valid CSP host-source + ], + }); + try { + const { url } = await controller.start(); + const res = await fetch(url); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(csp).toBe( + "frame-ancestors http://localhost:6274 http://127.0.0.1:6274", + ); + } finally { + await controller.close(); + } + }); + it("returns 404 for paths other than /sandbox", async () => { const controller = createSandboxController({ port: 0 }); try { diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index ef9c0f576..ef3d10bed 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -112,7 +112,14 @@ describe("buildWebServerConfigFromEnv", () => { process.env.DANGEROUSLY_BIND_ALL_INTERFACES = "true"; const cfg = buildWebServerConfigFromEnv(); expect(cfg.hostname).toBe("0.0.0.0"); - expect(cfg.allowedOrigins).toEqual(["http://0.0.0.0:8123"]); + // A wildcard bind serves loopback, so the default allow-list is the + // loopback trio (what a `docker run -p` browser actually sends), not the + // unmatchable http://0.0.0.0:PORT. + expect(cfg.allowedOrigins).toEqual([ + "http://localhost:8123", + "http://127.0.0.1:8123", + "http://[::1]:8123", + ]); }); it("expands a loopback HOST into all equivalent loopback origins", () => { @@ -246,15 +253,25 @@ describe("defaultAllowedOrigins", () => { }, ); - it("returns a single exact origin for a non-loopback host", () => { - expect(defaultAllowedOrigins("0.0.0.0", 8123)).toEqual([ - "http://0.0.0.0:8123", - ]); + it("returns a single exact origin for a specific non-loopback host", () => { expect(defaultAllowedOrigins("192.168.1.50", 6274)).toEqual([ "http://192.168.1.50:6274", ]); }); + it.each(["0.0.0.0", "::", "::0"])( + "returns the loopback trio for the all-interfaces host %j", + (host) => { + // A wildcard bind serves loopback; the wildcard address itself is never + // sent as an Origin, so http://:PORT would be dead weight. + expect(defaultAllowedOrigins(host, 8123)).toEqual([ + "http://localhost:8123", + "http://127.0.0.1:8123", + "http://[::1]:8123", + ]); + }, + ); + it("lowercases a non-loopback hostname to match the browser's Origin", () => { expect(defaultAllowedOrigins("Example.COM", 6274)).toEqual([ "http://example.com:6274", diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index c9e15d757..1ecf512b2 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -1,12 +1,20 @@ +import { isIPv6 } from "node:net"; + /** * Format a bind host for use inside a URL authority. An IPv6 literal must be * bracketed (`http://[::1]:6274`); every other host — loopback names, IPv4, * hostnames, already-bracketed IPv6 — passes through unchanged. Shared by the * web origin allow-list, the startup banner, the sandbox URL, and the CLI * deep-link so a bound IPv6 host is formatted the same way everywhere. + * + * Bracketing keys off `net.isIPv6`, not the presence of a `:`, so a mistyped + * `host:port` isn't wrapped as `[host:port]`. A zone index (`%eth0`) is dropped: + * a URL authority cannot carry one (`new URL()` rejects it even `%25`-encoded), + * and the zone is host-local and meaningless to a remote client anyway. */ export function formatHostForUrl(host: string): string { const h = host.trim(); - if (h.startsWith("[") || !h.includes(":")) return h; - return `[${h}]`; + if (h.startsWith("[")) return h; // already a bracketed IPv6 literal + const [address] = h.split("%"); // strip any IPv6 zone id + return isIPv6(address) ? `[${address}]` : h; } From 43a9b47fc8653c4fe519c33cf56200e64f205bb3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 17:36:55 -0400 Subject: [PATCH 08/31] =?UTF-8?q?review:=20round-6=20fixes=20=E2=80=94=20e?= =?UTF-8?q?mpty-ALLOWED=5FORIGINS=20fail-open,=200.0.0.0=20is=20a=20real?= =?UTF-8?q?=20origin=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-6 review of #1796: - H1: ALLOWED_ORIGINS that parses to an empty list ("", " ", ",") now falls back to the default allow-list instead of [] — the origin middleware treats [] as allow-all, so a blank value silently disabled the DNS-rebinding guard (fail open). Now fails closed. Tests added. - H2: 0.0.0.0 IS a real, locally-connectable browser origin (my round-5 premise was wrong), and the banner prints http://0.0.0.0:PORT — the user's only source for the token in Docker. So (a) defaultAllowedOrigins includes the bound-host origin alongside the loopback trio for a wildcard bind, and (b) printServerBanner advertises localhost for a wildcard bind, so the printed URL is both reachable and allow-listed. Comment corrected. - H3: root README notes a remapped Docker port needs ALLOWED_ORIGINS. - H4a: test the run-web bind-guard failure path (actionable message, not a stack trace). H4c: formatHostForUrl is now total — a bracketed-with-zone host yields a valid URL authority. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- README.md | 2 +- clients/web/server/web-server-config.ts | 54 ++++++++++++----- .../web/src/test/core/node/hostUrl.test.ts | 1 + .../test/integration/server/run-web.test.ts | 21 +++++++ .../server/web-server-config.test.ts | 58 ++++++++++++++----- core/node/hostUrl.ts | 7 ++- 6 files changed, 110 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 82097a7d3..20bb80312 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ docker build -t mcp-inspector . docker run --rm -p 6274:6274 mcp-inspector ``` -The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). ## Contributing — `AGENTS.md` and `CLAUDE.md` diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 2447fdf33..90ac33871 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -168,9 +168,14 @@ export function printServerBanner( resolvedToken: string, sandboxUrl: string | undefined, ): string { - // Uses httpOrigin so the banner and the origin allow-list agree on the - // default-port form (both drop :80). - const baseUrl = httpOrigin(formatHostForUrl(config.hostname), actualPort); + // Advertise `localhost` for a wildcard bind: `http://0.0.0.0:PORT` is an + // awkward URL to click, so point the user at a loopback address that's both + // reachable and in the default allow-list. Uses httpOrigin so the banner and + // the origin allow-list agree on the default-port form (both drop :80). + const bannerHost = isAllInterfacesHost(config.hostname) + ? "localhost" + : formatHostForUrl(config.hostname); + const baseUrl = httpOrigin(bannerHost, actualPort); const url = config.dangerouslyOmitAuth || !resolvedToken ? baseUrl @@ -223,13 +228,15 @@ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); * `localhost` the banner advertised. * * An **all-interfaces** bind (`0.0.0.0` / `::`, the opt-in path the Docker image - * uses) also serves loopback, so it gets the same loopback trio: a browser - * reaching the container via `docker run -p 6274:6274` navigates to - * `http://localhost:6274` and sends that as its `Origin`. The bind host itself - * (`0.0.0.0`) is never sent as an `Origin`, so a `http://0.0.0.0:PORT` entry - * would only ever be dead weight. (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.) + * uses) also serves loopback, so it gets the loopback trio *plus the bound-host + * origin*: a browser reaching the container via `docker run -p 6274:6274` + * navigates to `http://localhost:6274` (the trio), but `0.0.0.0` is itself + * locally connectable and the startup banner prints `http://0.0.0.0:PORT`, so a + * user who pastes that URL sends `http://0.0.0.0:6274` as its `Origin` — hence + * that entry is included too. Including it weakens nothing (no attacker document + * can claim that 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 — lowercased and, for an IPv6 literal, bracketed, so it matches @@ -248,13 +255,23 @@ export function defaultAllowedOrigins( port: number, ): string[] { const h = hostname.toLowerCase(); - if (LOOPBACK_HOSTNAMES.has(h) || isAllInterfacesHost(hostname)) { + if (LOOPBACK_HOSTNAMES.has(h)) { return [ httpOrigin("localhost", port), httpOrigin("127.0.0.1", port), httpOrigin("[::1]", port), ]; } + if (isAllInterfacesHost(hostname)) { + // Loopback trio (reachable via the wildcard) + the bound-host origin the + // banner prints (e.g. http://0.0.0.0:PORT), which is locally connectable. + return [ + httpOrigin("localhost", port), + httpOrigin("127.0.0.1", port), + httpOrigin("[::1]", port), + httpOrigin(formatHostForUrl(h), port), + ]; + } return [httpOrigin(formatHostForUrl(h), port)]; } @@ -294,6 +311,14 @@ export function buildWebServerConfig( ); } + // Fall back to the default when ALLOWED_ORIGINS parses to an empty list + // (`""`, `" "`, `","`). `[]` is not `undefined`, so a bare `??` would let it + // through — and the origin middleware treats an empty allow-list as + // *allow-all*, silently disabling the DNS-rebinding guard (fail open). + const configuredOrigins = process.env.ALLOWED_ORIGINS?.split(",") + .map((o) => o.trim()) + .filter(Boolean); + return { port, hostname, @@ -304,10 +329,9 @@ export function buildWebServerConfig( writable, initialServers, storageDir: process.env.MCP_STORAGE_DIR, - allowedOrigins: - process.env.ALLOWED_ORIGINS?.split(",") - .map((o) => o.trim()) - .filter(Boolean) ?? defaultAllowedOrigins(hostname, port), + allowedOrigins: configuredOrigins?.length + ? configuredOrigins + : defaultAllowedOrigins(hostname, port), sandboxPort, sandboxHost: hostname, logger, diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index 70bb55db6..d248e08cb 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -8,6 +8,7 @@ describe("formatHostForUrl", () => { [" ::1 ", "[::1]"], ["fe80::1%eth0", "[fe80::1]"], // zone id dropped — a URL host can't carry one ["::1%lo0", "[::1]"], + ["[fe80::1%eth0]", "[fe80::1]"], // bracketed-with-zone → still a valid URL host ])("brackets the IPv6 literal %j", (host, expected) => { expect(formatHostForUrl(host)).toBe(expected); }); diff --git a/clients/web/src/test/integration/server/run-web.test.ts b/clients/web/src/test/integration/server/run-web.test.ts index 28b291040..65bd23403 100644 --- a/clients/web/src/test/integration/server/run-web.test.ts +++ b/clients/web/src/test/integration/server/run-web.test.ts @@ -42,6 +42,7 @@ describe("runWeb", () => { let exitSpy: ReturnType; let exitCode: number | undefined; let catalogEnvSnapshot: string | undefined; + let hostEnvSnapshot: string | undefined; const signalHandlers = new Map void>(); beforeEach(() => { @@ -54,6 +55,8 @@ describe("runWeb", () => { exitCode = undefined; catalogEnvSnapshot = process.env.MCP_CATALOG_PATH; delete process.env.MCP_CATALOG_PATH; + hostEnvSnapshot = process.env.HOST; + delete process.env.HOST; logLines = []; warnLines = []; @@ -98,6 +101,11 @@ describe("runWeb", () => { } else { process.env.MCP_CATALOG_PATH = catalogEnvSnapshot; } + if (hostEnvSnapshot === undefined) { + delete process.env.HOST; + } else { + process.env.HOST = hostEnvSnapshot; + } }); async function expectServerStarted( @@ -162,6 +170,19 @@ describe("runWeb", () => { expect(startHonoServer).not.toHaveBeenCalled(); }); + it("surfaces the bind-guard error as an actionable message, not a stack trace", async () => { + // HOST=0.0.0.0 without the opt-in makes buildWebServerConfig throw; run-web + // must catch it and print "Error: …" rather than let it bubble as a raw + // stack trace from the launcher's top-level handler. + process.env.HOST = "0.0.0.0"; + await expect(runWeb(["node", "run-web"])).rejects.toThrow("process.exit:1"); + expect( + errorLines.some((l) => l.includes("DANGEROUSLY_BIND_ALL_INTERFACES")), + ).toBe(true); + expect(startHonoServer).not.toHaveBeenCalled(); + expect(startViteDevServer).not.toHaveBeenCalled(); + }); + it("starts Vite when --dev is set", async () => { void runWeb(["node", "run-web", "--dev"]); await expectServerStarted(startViteDevServer); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index ef3d10bed..d554fdf68 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -113,12 +113,13 @@ describe("buildWebServerConfigFromEnv", () => { const cfg = buildWebServerConfigFromEnv(); expect(cfg.hostname).toBe("0.0.0.0"); // A wildcard bind serves loopback, so the default allow-list is the - // loopback trio (what a `docker run -p` browser actually sends), not the - // unmatchable http://0.0.0.0:PORT. + // loopback trio (what a `docker run -p` browser actually sends) plus the + // bound-host origin the banner prints (http://0.0.0.0:PORT). expect(cfg.allowedOrigins).toEqual([ "http://localhost:8123", "http://127.0.0.1:8123", "http://[::1]:8123", + "http://0.0.0.0:8123", ]); }); @@ -172,6 +173,21 @@ describe("buildWebServerConfigFromEnv", () => { expect(cfg.allowedOrigins).toEqual(["http://a:1", "http://b:2"]); }); + it.each(["", " ", ","])( + "falls back to the default (not an empty allow-all) when ALLOWED_ORIGINS is %j", + (value) => { + // An empty parsed list must NOT reach the middleware — it treats an empty + // allow-list as allow-all, which would silently disable the origin guard. + process.env.ALLOWED_ORIGINS = value; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.allowedOrigins).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); + }, + ); + it("resolves sandboxPort from MCP_SANDBOX_PORT", () => { process.env.MCP_SANDBOX_PORT = "9001"; expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9001); @@ -259,18 +275,25 @@ describe("defaultAllowedOrigins", () => { ]); }); - it.each(["0.0.0.0", "::", "::0"])( - "returns the loopback trio for the all-interfaces host %j", - (host) => { - // A wildcard bind serves loopback; the wildcard address itself is never - // sent as an Origin, so http://:PORT would be dead weight. - expect(defaultAllowedOrigins(host, 8123)).toEqual([ - "http://localhost:8123", - "http://127.0.0.1:8123", - "http://[::1]:8123", - ]); - }, - ); + it("returns the loopback trio plus the bound-host origin for the 0.0.0.0 wildcard", () => { + // A wildcard bind serves loopback (the trio) and 0.0.0.0 is itself locally + // connectable — the banner prints http://0.0.0.0:PORT, so it's included too. + expect(defaultAllowedOrigins("0.0.0.0", 8123)).toEqual([ + "http://localhost:8123", + "http://127.0.0.1:8123", + "http://[::1]:8123", + "http://0.0.0.0:8123", + ]); + }); + + it("brackets the bound-host origin for the :: wildcard", () => { + expect(defaultAllowedOrigins("::", 8123)).toEqual([ + "http://localhost:8123", + "http://127.0.0.1:8123", + "http://[::1]:8123", + "http://[::]:8123", + ]); + }); it("lowercases a non-loopback hostname to match the browser's Origin", () => { expect(defaultAllowedOrigins("Example.COM", 6274)).toEqual([ @@ -481,6 +504,13 @@ describe("printServerBanner", () => { expect(url).toBe("http://[::1]:6274"); }); + it("advertises localhost for a wildcard bind rather than the wildcard host", () => { + const cfg = baseConfig(); + cfg.hostname = "0.0.0.0"; + const url = printServerBanner(cfg, 6274, "", undefined); + expect(url).toBe("http://localhost:6274"); + }); + it("prints the sandbox URL when provided", () => { printServerBanner(baseConfig(), 6274, "tok", "http://sandbox:9999/sandbox"); expect( diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index 1ecf512b2..d606298df 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -14,7 +14,8 @@ import { isIPv6 } from "node:net"; */ export function formatHostForUrl(host: string): string { const h = host.trim(); - if (h.startsWith("[")) return h; // already a bracketed IPv6 literal - const [address] = h.split("%"); // strip any IPv6 zone id - return isIPv6(address) ? `[${address}]` : h; + // Strip surrounding brackets and any zone id first, so the helper is total — + // a bracketed-with-zone input (`[fe80::1%eth0]`) still yields a valid URL host. + const bare = h.replace(/^\[(.*)\]$/, "$1").split("%")[0]; + return isIPv6(bare) ? `[${bare}]` : h; } From 7a638ea1e865498dde9fedfe18732ebf49463744 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 17:46:27 -0400 Subject: [PATCH 09/31] =?UTF-8?q?review:=20round-7=20fixes=20=E2=80=94=20c?= =?UTF-8?q?anonical=20wildcard=20origins,=20sandbox=20URL,=20dedup=20(#179?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-7 review of #1796: - J1: defaultAllowedOrigins emits the CANONICAL wildcard pair (http://0.0.0.0:PORT + http://[::]:PORT) for any all-interfaces bind, not the typed HOST spelling. Browsers canonicalize the address before building the Origin (HOST=0/0x0/0.0.0 → 0.0.0.0; ::0 → [::]), so echoing the raw spelling produced an entry the browser could never send (403). Also covers a dual-stack :: bind serving IPv4, and drops the http://:PORT garbage from HOST="". Retires the F1→H2→J1 "unmatchable entry" class. - J2: the sandbox URL advertises localhost for a wildcard bind (served to the client via /api/config + printed in the banner) — 0.0.0.0 isn't reachable but a wildcard bind serves loopback. README caveat trimmed accordingly. - J3: reworded the (now-stale) allow-list comment and README wildcard line. - J4: fallback-CSP test uses toBe (not toContain) so re-adding a source fails; v8-ignore the provably-dead non-Error branch in run-web; extract loopbackOrigins() to stop the trio drifting; soften the formatHostForUrl "total" comment. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 4 +- clients/web/server/run-web.ts | 5 ++- clients/web/server/sandbox-controller.ts | 10 ++++- clients/web/server/web-server-config.ts | 43 +++++++++++-------- .../server/sandbox-controller.test.ts | 23 +++++++--- .../server/web-server-config.test.ts | 37 ++++++++-------- core/node/hostUrl.ts | 6 ++- 7 files changed, 77 insertions(+), 51 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 4908b400f..a90afbecf 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -171,11 +171,11 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe - **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. -- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it 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: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. +- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it 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: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts but **rejects a named hostname** unless you add it. So a bare `HOST=` works out of the box for the prod server but needs `server.allowedHosts` configured (or a specific IP) under `--dev` — for network hosting, prefer the prod server (`mcp-inspector --web`) or bind a specific IP. -**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default) and its URL is built from the bind host. For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). Under the `0.0.0.0` wildcard the sandbox URL becomes `http://0.0.0.0:`, which the client can't reach; bind a specific address if you need MCP Apps over the network. Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. +**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index b1412d8c4..f1ed5a11f 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -247,7 +247,10 @@ export async function runWeb(argv: string[]): Promise { // 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 : "Invalid web server configuration."; + 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); } diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 8820328aa..06c83e661 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -8,6 +8,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { formatHostForUrl } from "../../../core/node/hostUrl.ts"; +import { isAllInterfacesHost } from "./resolve-bind-host.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -178,7 +179,14 @@ export function createSandboxController( string form only occurs for unix-socket/pipe listens, which this controller never performs. */ (addr as unknown as number); - sandboxUrl = `http://${formatHostForUrl(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. + const urlHost = isAllInterfacesHost(host) + ? "localhost" + : formatHostForUrl(host); + sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`; settle({ port: actualPort, url: sandboxUrl }); }); }); diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 90ac33871..5b86af149 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -228,14 +228,17 @@ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); * `localhost` the banner advertised. * * 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 bound-host - * origin*: a browser reaching the container via `docker run -p 6274:6274` - * navigates to `http://localhost:6274` (the trio), but `0.0.0.0` is itself - * locally connectable and the startup banner prints `http://0.0.0.0:PORT`, so a - * user who pastes that URL sends `http://0.0.0.0:6274` as its `Origin` — hence - * that entry is included too. Including it weakens nothing (no attacker document - * can claim that origin without the user having navigated there). Reaching the - * server at a non-loopback address — a LAN IP, a public hostname — still needs + * 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 @@ -250,26 +253,28 @@ 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), + ]; +} + export function defaultAllowedOrigins( hostname: string, port: number, ): string[] { const h = hostname.toLowerCase(); if (LOOPBACK_HOSTNAMES.has(h)) { - return [ - httpOrigin("localhost", port), - httpOrigin("127.0.0.1", port), - httpOrigin("[::1]", port), - ]; + return loopbackOrigins(port); } if (isAllInterfacesHost(hostname)) { - // Loopback trio (reachable via the wildcard) + the bound-host origin the - // banner prints (e.g. http://0.0.0.0:PORT), which is locally connectable. return [ - httpOrigin("localhost", port), - httpOrigin("127.0.0.1", port), - httpOrigin("[::1]", port), - httpOrigin(formatHostForUrl(h), port), + ...loopbackOrigins(port), + httpOrigin("0.0.0.0", port), + httpOrigin("[::]", port), ]; } return [httpOrigin(formatHostForUrl(h), port)]; diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 443075975..bd6078456 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -131,12 +131,10 @@ describe("createSandboxController", () => { // container, so any default-src/connect-src here would intersect with and // override the per-app CSP baked into the inner document. const csp = res.headers.get("content-security-policy") ?? ""; - // Assert the full directive (not a prefix) so the fallback is - // regression-guarded. No `[::1]` — a bracketed IPv6 literal is not a - // valid CSP host-source. - expect(csp).toContain( - "frame-ancestors http://127.0.0.1:* http://localhost:*", - ); + // Assert the FULL directive (toBe, not toContain) so re-adding a source — + // e.g. the `http://[::1]:*` that F2 proved harmful — would fail the test. + // No `[::1]` — a bracketed IPv6 literal is not a valid CSP host-source. + expect(csp).toBe("frame-ancestors http://127.0.0.1:* http://localhost:*"); expect(csp).not.toContain("default-src"); expect(csp).not.toContain("connect-src"); const body = await res.text(); @@ -171,6 +169,19 @@ describe("createSandboxController", () => { } }); + it("advertises localhost in the sandbox URL for a wildcard bind", async () => { + // 0.0.0.0 isn't reachable from the browser, but a wildcard bind serves + // loopback — so the URL handed to the client (and printed in the banner) + // uses localhost. + const controller = createSandboxController({ port: 0, host: "0.0.0.0" }); + try { + const { url, port } = await controller.start(); + expect(url).toBe(`http://localhost:${port}/sandbox`); + } finally { + await controller.close(); + } + }); + it("returns 404 for paths other than /sandbox", async () => { const controller = createSandboxController({ port: 0 }); try { diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index d554fdf68..cd4115f35 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -114,12 +114,13 @@ describe("buildWebServerConfigFromEnv", () => { expect(cfg.hostname).toBe("0.0.0.0"); // A wildcard bind serves loopback, so the default allow-list is the // loopback trio (what a `docker run -p` browser actually sends) plus the - // bound-host origin the banner prints (http://0.0.0.0:PORT). + // canonical wildcard pair (0.0.0.0 / [::]). expect(cfg.allowedOrigins).toEqual([ "http://localhost:8123", "http://127.0.0.1:8123", "http://[::1]:8123", "http://0.0.0.0:8123", + "http://[::]:8123", ]); }); @@ -275,25 +276,21 @@ describe("defaultAllowedOrigins", () => { ]); }); - it("returns the loopback trio plus the bound-host origin for the 0.0.0.0 wildcard", () => { - // A wildcard bind serves loopback (the trio) and 0.0.0.0 is itself locally - // connectable — the banner prints http://0.0.0.0:PORT, so it's included too. - expect(defaultAllowedOrigins("0.0.0.0", 8123)).toEqual([ - "http://localhost:8123", - "http://127.0.0.1:8123", - "http://[::1]:8123", - "http://0.0.0.0:8123", - ]); - }); - - it("brackets the bound-host origin for the :: wildcard", () => { - expect(defaultAllowedOrigins("::", 8123)).toEqual([ - "http://localhost:8123", - "http://127.0.0.1:8123", - "http://[::1]:8123", - "http://[::]:8123", - ]); - }); + // Any wildcard spelling yields the loopback trio + the CANONICAL wildcard pair + // (0.0.0.0 / [::]) — not the typed spelling, which the browser canonicalizes + // away (HOST=0 / 0x0 / 0.0.0 all send http://0.0.0.0:PORT; ::0 sends [::]). + it.each(["0.0.0.0", "::", "::0", "0", "0x0", "0.0.0"])( + "returns the loopback trio + canonical wildcard pair for the all-interfaces host %j", + (host) => { + expect(defaultAllowedOrigins(host, 8123)).toEqual([ + "http://localhost:8123", + "http://127.0.0.1:8123", + "http://[::1]:8123", + "http://0.0.0.0:8123", + "http://[::]:8123", + ]); + }, + ); it("lowercases a non-loopback hostname to match the browser's Origin", () => { expect(defaultAllowedOrigins("Example.COM", 6274)).toEqual([ diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index d606298df..ac85e56c1 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -14,8 +14,10 @@ import { isIPv6 } from "node:net"; */ export function formatHostForUrl(host: string): string { const h = host.trim(); - // Strip surrounding brackets and any zone id first, so the helper is total — - // a bracketed-with-zone input (`[fe80::1%eth0]`) still yields a valid URL host. + // Strip surrounding brackets and any zone id before the IPv6 check, so a + // bracketed-with-zone input (`[fe80::1%eth0]`) still yields a valid URL host. + // (A non-IPv6 value is returned as-given — this normalizes IPv6 literals, it + // doesn't validate arbitrary hosts.) const bare = h.replace(/^\[(.*)\]$/, "$1").split("%")[0]; return isIPv6(bare) ? `[${bare}]` : h; } From b95505620d2e0ee699ef847dcf9586f9aa06780f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 17:57:11 -0400 Subject: [PATCH 10/31] =?UTF-8?q?review:=20round-8=20fixes=20=E2=80=94=20n?= =?UTF-8?q?ormalize=20ALLOWED=5FORIGINS,=20reject=20unusable=20CLIENT=5FPO?= =?UTF-8?q?RT=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-8 review of #1796 (reviewer marked the bind guard converged): - M1: canonicalize each ALLOWED_ORIGINS entry via new URL(o).origin (drops a trailing slash/path, lowercases the host, drops the default :80), so the natural copy-paste forms — http://localhost:6274/ from the address bar, an uppercase host, an explicit :80 — match the browser's canonical Origin instead of 403ing on an exact-string compare. Unparseable entries are warned + dropped; if none survive, the H1 fallback to defaultAllowedOrigins (fail closed) stands. - M2: reject an unusable CLIENT_PORT (0/dynamic, NaN, out of range) with an actionable error — the allow-list and sandbox CSP are derived from the port, so a dynamic port would 403 every connect and block the Apps iframe. - M3: README notes ALLOWED_ORIGINS entries are canonicalized, a blank value does NOT disable the check (fail closed, no off knob), and the specific-IP bind brackets IPv6 / drops :80. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 4 +- clients/web/server/web-server-config.ts | 34 ++++++++++++--- .../server/web-server-config.test.ts | 43 ++++++++++++++++++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index a90afbecf..00bf635e4 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -163,13 +163,13 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. -The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override. +The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. ### Hosting on a network The guard blocks only the **wildcard** all-interfaces addresses. Binding a **specific** IP or hostname is allowed with no opt-in — that's a single, deliberate exposure, unlike the wildcard which binds every interface at once (the pattern DNS-rebinding exploits). To serve the Inspector on a LAN or the internet: -- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. +- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. (An IPv6 bind host is bracketed — `http://[2001:db8::1]:PORT` — and the port is dropped when it's the http default `:80`, matching what the browser sends.) - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. - **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it 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: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 5b86af149..d418d4b75 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -294,6 +294,15 @@ export function buildWebServerConfig( initialServers = null, } = options; const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10); + // A fixed port is required: the origin allow-list and the sandbox CSP are both + // built from it, so `0` (OS-assigned) would make them reference a port the + // server isn't on — every connect 403s and the MCP Apps iframe is blocked. + // Fail fast with an actionable message (run-web surfaces it cleanly). + if (!Number.isInteger(port) || 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 @@ -316,13 +325,28 @@ export function buildWebServerConfig( ); } - // Fall back to the default when ALLOWED_ORIGINS parses to an empty list - // (`""`, `" "`, `","`). `[]` is not `undefined`, so a bare `??` would let it - // through — and the origin middleware treats an empty allow-list as - // *allow-all*, silently disabling the DNS-rebinding guard (fail open). + // Parse ALLOWED_ORIGINS into canonical origins. Each entry is normalized via + // `new URL(o).origin` (drops a trailing slash/path, lowercases the host, drops + // the default :80) so the natural copy-paste forms — `http://localhost:6274/` + // from the address bar, an uppercase host, an explicit `:80` — match the + // canonical `Origin` the browser sends rather than 403ing on an exact-string + // compare. Unparseable entries are warned and dropped. + // + // 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); + .filter(Boolean) + .map((o) => { + try { + return new URL(o).origin; + } catch { + console.warn(`Ignoring invalid ALLOWED_ORIGINS entry: ${o}`); + return null; + } + }) + .filter((o): o is string => o !== null); return { port, diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index cd4115f35..9809a050c 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { buildWebServerConfig, buildWebServerConfigFromEnv, @@ -189,6 +189,47 @@ describe("buildWebServerConfigFromEnv", () => { }, ); + it("canonicalizes ALLOWED_ORIGINS entries so copy-paste forms still match", () => { + // Trailing slash, uppercase host, explicit default :80 — all normalized to + // the canonical Origin the browser actually sends. + process.env.ALLOWED_ORIGINS = + "http://localhost:6274/, http://Example.COM:6274, http://myhost:80"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.allowedOrigins).toEqual([ + "http://localhost:6274", + "http://example.com:6274", + "http://myhost", + ]); + }); + + it("drops unparseable ALLOWED_ORIGINS entries (and falls back if none survive)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + process.env.ALLOWED_ORIGINS = "not a url, *, http://ok:1"; + const cfg = buildWebServerConfigFromEnv(); + // "not a url" / "*" throw; "http://ok:1" parses. + expect(cfg.allowedOrigins).toEqual(["http://ok:1"]); + + process.env.ALLOWED_ORIGINS = "not a url, also bad"; + const cfg2 = buildWebServerConfigFromEnv(); + expect(cfg2.allowedOrigins).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); + } finally { + warnSpy.mockRestore(); + } + }); + + it.each(["0", "abc", "70000", "-1"])( + "rejects an unusable CLIENT_PORT %j with an actionable error", + (value) => { + process.env.CLIENT_PORT = value; + expect(() => buildWebServerConfigFromEnv()).toThrow(/CLIENT_PORT/); + }, + ); + it("resolves sandboxPort from MCP_SANDBOX_PORT", () => { process.env.MCP_SANDBOX_PORT = "9001"; expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9001); From b2ac92b0928dbd49fcb651eeefd9b69d30e54324 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 18:07:54 -0400 Subject: [PATCH 11/31] =?UTF-8?q?review:=20round-9=20fixes=20=E2=80=94=20r?= =?UTF-8?q?eject=20scheme-less=20ALLOWED=5FORIGINS,=20CLIENT=5FPORT=20poli?= =?UTF-8?q?sh=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-9 review of #1796: - P1: reject an ALLOWED_ORIGINS entry whose canonical origin is the literal "null" — a scheme-less value (localhost:6274) or a file:/about:/javascript: URL doesn't throw in new URL(); .origin is "null". Silently allow-listing it both failed to allow-list what the user meant AND matched the real Origin:null header browsers send from opaque origins (sandboxed iframes, data: docs). Now warned + dropped, so the fail-closed fallback covers all-invalid. README notes the scheme is required. - P2a: treat empty CLIENT_PORT as unset (|| "6274"), matching resolveSandboxPort. - P2b: ^\d+$ guard so parseInt's partial parses (6274abc, 80.9) are rejected. - P2c: vite.config builds the validated dev config once and reads port/host from it, removing the implicit plugins-before-server ordering the guard relied on. - Verified the refactored dev server boots and the origin guard works E2E (matching origin → 400, evil.com → 403). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 2 +- clients/web/server/web-server-config.ts | 24 ++++++--- .../server/web-server-config.test.ts | 31 ++++++++++- clients/web/vite.config.ts | 54 +++++++++---------- 4 files changed, 75 insertions(+), 36 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 00bf635e4..43af40e53 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -163,7 +163,7 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. -The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. +The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. ### Hosting on a network diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index d418d4b75..50c5b24f9 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -293,12 +293,16 @@ export function buildWebServerConfig( writable = true, initialServers = null, } = options; - const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10); - // A fixed port is required: the origin allow-list and the sandbox CSP are both - // built from it, so `0` (OS-assigned) would make them reference a port the - // server isn't on — every connect 403s and the MCP Apps iframe is blocked. + // 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). - if (!Number.isInteger(port) || port < 1 || port > 65535) { + 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).`, ); @@ -340,7 +344,15 @@ export function buildWebServerConfig( .filter(Boolean) .map((o) => { try { - return new URL(o).origin; + 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 `file:`, `about:`, `javascript:`, `data:`). 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 must carry a scheme. + if (origin === "null") throw new Error("opaque origin"); + return origin; } catch { console.warn(`Ignoring invalid ALLOWED_ORIGINS entry: ${o}`); return null; diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 9809a050c..3888560b9 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -222,7 +222,30 @@ describe("buildWebServerConfigFromEnv", () => { } }); - it.each(["0", "abc", "70000", "-1"])( + it('drops scheme-less / opaque ALLOWED_ORIGINS entries rather than allow-listing "null"', () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // `new URL("localhost:6274").origin` is the literal "null" (not a throw) — + // must be dropped, not allow-listed (it'd match a real `Origin: null`). + process.env.ALLOWED_ORIGINS = + "localhost:6274, file:///srv, http://real:1"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.allowedOrigins).toEqual(["http://real:1"]); + + // All scheme-less → nothing survives → fail-closed fallback to default. + process.env.ALLOWED_ORIGINS = "localhost:6274, myhost:8080"; + const cfg2 = buildWebServerConfigFromEnv(); + expect(cfg2.allowedOrigins).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); + } finally { + warnSpy.mockRestore(); + } + }); + + it.each(["0", "abc", "70000", "-1", "6274abc", "80.9"])( "rejects an unusable CLIENT_PORT %j with an actionable error", (value) => { process.env.CLIENT_PORT = value; @@ -230,6 +253,12 @@ describe("buildWebServerConfigFromEnv", () => { }, ); + it("treats an empty CLIENT_PORT as unset (defaults to 6274)", () => { + process.env.CLIENT_PORT = ""; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.port).toBe(6274); + }); + it("resolves sandboxPort from MCP_SANDBOX_PORT", () => { process.env.MCP_SANDBOX_PORT = "9001"; expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9001); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 3c5674bf7..46368a579 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -13,7 +13,6 @@ import { getViteDevOptimizeDeps, } from "./server/vite-base-config"; import { buildWebServerConfigFromEnv } from "./server/web-server-config"; -import { resolveBindHostname } from "./server/resolve-bind-host"; import { createBrowserExternalizedBuiltinGate } from "./server/browser-externalized-builtin-gate"; import { vitestSharedPaths } from "../../vitest.shared.mts"; const dirname = @@ -94,15 +93,22 @@ function browserExternalizedBuiltinGate(): Plugin { // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig(({ command }) => { const isDevServer = command === "serve" && !process.env.VITEST; + // Build the validated dev backend config ONCE (when serving) and reuse it for + // both the Hono plugin and the `server` block below, so the dev server's + // `port`/`host` come from the same guard-checked source (`resolveBindHostname` + // + the CLIENT_PORT validation) rather than a second raw parse. This also + // removes the implicit "plugins must be evaluated before server" ordering the + // guard previously relied on to throw first. + const devConfig = isDevServer ? buildWebServerConfigFromEnv() : undefined; return { // `honoMiddlewarePlugin` only attaches during `vite dev` / `vite preview`. // It's included conditionally on `isDevServer` (not merely `apply: 'serve'`) - // because its config *argument* — `buildWebServerConfigFromEnv()`, which - // calls `resolveBindHostname()` — is evaluated eagerly when this array is - // built. Left unconditional, an ambient `HOST=0.0.0.0` would make the guard - // throw at config load for `vite build` and every vitest project too, not - // just when serving. Gating the whole plugin also skips that wasted config - // build for non-serve commands. + // because `devConfig` (from `buildWebServerConfigFromEnv()`, which calls + // `resolveBindHostname()`) is built eagerly above. Left unconditional, an + // ambient `HOST=0.0.0.0` would make the guard throw at config load for + // `vite build` and every vitest project too, not just when serving. Gating + // the whole plugin also skips that wasted config build for non-serve + // commands. // // The plugin statically imports the node-only dev backend // (`core/mcp/remote/node/server.ts`), so Vite's config bundler (Rolldown) @@ -119,9 +125,7 @@ export default defineConfig(({ command }) => { // reaches the browser bundle (#1769) — see its definition above. plugins: [ react(), - ...(isDevServer - ? [honoMiddlewarePlugin(buildWebServerConfigFromEnv())] - : []), + ...(devConfig ? [honoMiddlewarePlugin(devConfig)] : []), browserExternalizedBuiltinGate(), ], // Shared optimizeDeps exclusions so node-only packages @@ -154,25 +158,19 @@ export default defineConfig(({ command }) => { // location (which has no node_modules of its own yet). dedupe: sharedDedupe, }, - // Pin the Vite dev server to the same port (and host) the Hono plugin - // configures from env, so `allowedOrigins` actually matches the browser - // origin. Without this, `vite dev` falls back to Vite's default 5173 - // while the dev backend's `buildWebServerConfigFromEnv()` defaults to - // CLIENT_PORT=6274 — origin check rejects every `/api/*` request from - // the browser. CLIENT_PORT / HOST overrides flow through here too. - // `strictPort: true` so a port collision fails loudly instead of - // silently picking a different port (which would leave `allowedOrigins` - // pointing at the wrong host and break browser fetches). + // Pin the Vite dev server to the same port and host the Hono plugin + // configures, so `allowedOrigins` actually matches the browser origin. + // Without this, `vite dev` falls back to Vite's default 5173 while the dev + // backend defaults to CLIENT_PORT=6274 — origin check rejects every `/api/*` + // request. When serving, both come from the already-validated `devConfig` + // (guard-checked host + CLIENT_PORT); `vite build` and the vitest projects + // evaluate this config but never bind, so they fall back to the raw env + // (an ambient HOST=0.0.0.0 must not fail them at config load). + // `strictPort: true` so a port collision fails loudly instead of silently + // picking a different port (which would leave `allowedOrigins` wrong). server: { - port: parseInt(process.env.CLIENT_PORT ?? "6274", 10), - // Only enforce the bind-host guard when actually serving (`isDevServer` - // also excludes vitest, whose `command` is `serve`). `vite build` and the - // vitest projects evaluate this same config but never bind, so an ambient - // HOST=0.0.0.0 (e.g. exported in CI) must not fail them at config load - // with a message about binding. - host: isDevServer - ? resolveBindHostname() - : (process.env.HOST ?? "localhost"), + port: devConfig?.port ?? parseInt(process.env.CLIENT_PORT ?? "6274", 10), + host: devConfig?.hostname ?? process.env.HOST ?? "localhost", strictPort: true, fs: { allow: [path.resolve(dirname, "../..")], From a273d5b65c5e8ba3e492af1384fd132230f13682 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 18:17:45 -0400 Subject: [PATCH 12/31] =?UTF-8?q?review:=20round-10=20fixes=20=E2=80=94=20?= =?UTF-8?q?canonicalize=20the=20bind=20host=20on=20all=20origin-list=20bra?= =?UTF-8?q?nches=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-10 review of #1796 (reviewer marked it converged): - R1: defaultAllowedOrigins canonicalizes the bind host up front via canonicalUrlHost (formatHostForUrl → new URL().hostname), so a non-canonical spelling of a loopback address — 127.1 / 0x7f.0.0.1 / 2130706433 / 0:0:...:1 / ::0001 — is recognized as loopback (gets the trio) and any emitted origin is the canonical form the browser sends. Retires the F1→H2→J1→R1 class on the last branch that echoed raw input; 127.0.0.2 (a distinct address) still gets its single origin. - R2a: vite.config's non-serve CLIENT_PORT fallback uses `|| "6274"` (empty ⇒ unset), matching buildWebServerConfig. - R2b: reject a wildcard ALLOWED_ORIGINS entry (http://*.example.com) — it works for the CSP host-source but never for the exact-compare origin guard, the most confusing split; now warned + dropped. - R2c/R2d: comment that canonicalization also punycodes IDN hosts; clarify CSP_HOST_SOURCE's load-bearing job (dropping bracketed IPv6) post-M1. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/server/sandbox-controller.ts | 17 ++++---- clients/web/server/web-server-config.ts | 41 +++++++++++++++---- .../server/web-server-config.test.ts | 34 +++++++++++++++ clients/web/vite.config.ts | 7 +++- 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 06c83e661..a25d38958 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -37,14 +37,15 @@ 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` is user-controlled (the - * `ALLOWED_ORIGINS` env var) and its values are interpolated into the - * `Content-Security-Policy` response header — so a newline would make - * `writeHead` throw `ERR_INVALID_CHAR` (killing the sandbox page) and a `;` - * would inject extra CSP directives. Brackets are excluded too: a bracketed IPv6 - * literal (`http://[::1]:PORT`) is not a valid CSP host-source, so emitting it - * is at best dead weight and — as the only source — would block the frame. - * Only values matching this shape survive into {@link sandboxFrameAncestors}. + * 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 the header. Only values matching this + * shape survive into {@link sandboxFrameAncestors}. */ const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]]+$/i; diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 50c5b24f9..9fde70e9f 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -262,11 +262,30 @@ function loopbackOrigins(port: number): string[] { ]; } +/** + * Canonicalize a bind host the way a browser does before building `Origin`, so + * the allow-list entry matches the header. Non-canonical spellings of the same + * address — `127.1` / `0x7f.0.0.1` / `2130706433` all mean `127.0.0.1`, + * `0:0:0:0:0:0:0:1` / `::0001` mean `::1` — otherwise both miss the + * {@link LOOPBACK_HOSTNAMES} lookup (losing the loopback-trio expansion for an + * address that IS loopback) and emit an origin the browser can never send. + * `new URL().hostname` returns the URL-ready form (bracketed for IPv6); falls + * back to the formatted input if it isn't a parseable URL host. + */ +function canonicalUrlHost(host: string): string { + const formatted = formatHostForUrl(host.trim().toLowerCase()); + try { + return new URL(`http://${formatted}`).hostname; + } catch { + return formatted; + } +} + export function defaultAllowedOrigins( hostname: string, port: number, ): string[] { - const h = hostname.toLowerCase(); + const h = canonicalUrlHost(hostname); if (LOOPBACK_HOSTNAMES.has(h)) { return loopbackOrigins(port); } @@ -277,7 +296,8 @@ export function defaultAllowedOrigins( httpOrigin("[::]", port), ]; } - return [httpOrigin(formatHostForUrl(h), port)]; + // `h` is already the canonical, URL-ready host (bracketed for IPv6). + return [httpOrigin(h, port)]; } /** @@ -330,11 +350,12 @@ export function buildWebServerConfig( } // Parse ALLOWED_ORIGINS into canonical origins. Each entry is normalized via - // `new URL(o).origin` (drops a trailing slash/path, lowercases the host, drops - // the default :80) so the natural copy-paste forms — `http://localhost:6274/` - // from the address bar, an uppercase host, an explicit `:80` — match the - // canonical `Origin` the browser sends rather than 403ing on an exact-string - // compare. Unparseable entries are warned and dropped. + // `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 @@ -352,6 +373,12 @@ export function buildWebServerConfig( // send from opaque origins (a sandboxed iframe, a `data:` doc), so // allow-listing it would erode the guard. Entries must carry a scheme. 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}`); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 3888560b9..28da7356a 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -245,6 +245,18 @@ describe("buildWebServerConfigFromEnv", () => { } }); + it("drops a wildcard ALLOWED_ORIGINS entry (works for CSP but never for the origin check)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + process.env.ALLOWED_ORIGINS = + "http://*.example.com:6274, http://real.example.com:6274"; + const cfg = buildWebServerConfigFromEnv(); + expect(cfg.allowedOrigins).toEqual(["http://real.example.com:6274"]); + } finally { + warnSpy.mockRestore(); + } + }); + it.each(["0", "abc", "70000", "-1", "6274abc", "80.9"])( "rejects an unusable CLIENT_PORT %j with an actionable error", (value) => { @@ -346,6 +358,28 @@ describe("defaultAllowedOrigins", () => { ]); }); + // A non-canonical spelling of a loopback address is canonicalized (the way the + // browser canonicalizes it into `Origin`), so it's recognized as loopback and + // gets the trio — not a single unmatchable entry. + it.each(["127.1", "0x7f.0.0.1", "2130706433", "0:0:0:0:0:0:0:1", "::0001"])( + "canonicalizes the loopback spelling %j and returns the trio", + (host) => { + expect(defaultAllowedOrigins(host, 6274)).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); + }, + ); + + it("keeps a distinct non-loopback address (127.0.0.2) as a single origin", () => { + // 127.0.0.2 is canonical and a bind there doesn't serve 127.0.0.1, so the + // single-origin branch is correct — only non-canonical *spellings* expand. + expect(defaultAllowedOrigins("127.0.0.2", 6274)).toEqual([ + "http://127.0.0.2:6274", + ]); + }); + // Any wildcard spelling yields the loopback trio + the CANONICAL wildcard pair // (0.0.0.0 / [::]) — not the typed spelling, which the browser canonicalizes // away (HOST=0 / 0x0 / 0.0.0 all send http://0.0.0.0:PORT; ::0 sends [::]). diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 46368a579..08cd82a35 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -169,7 +169,12 @@ export default defineConfig(({ command }) => { // `strictPort: true` so a port collision fails loudly instead of silently // picking a different port (which would leave `allowedOrigins` wrong). server: { - port: devConfig?.port ?? parseInt(process.env.CLIENT_PORT ?? "6274", 10), + // The `|| "6274"` (empty ⇒ unset) mirrors buildWebServerConfig, so the + // non-serve fallback agrees with the validated dev path on a blank + // CLIENT_PORT rather than parsing it to NaN. + port: + devConfig?.port ?? + parseInt(process.env.CLIENT_PORT?.trim() || "6274", 10), host: devConfig?.hostname ?? process.env.HOST ?? "localhost", strictPort: true, fs: { From 96561b4302998a41f336d416e83a11cab2ccab30 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 18:28:09 -0400 Subject: [PATCH 13/31] =?UTF-8?q?review:=20round-11=20polish=20=E2=80=94?= =?UTF-8?q?=20verify=20--dev=20allowedHosts,=20dedupe=20port=20validation,?= =?UTF-8?q?=20doc=20fixes=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-11 review of #1796 (reviewer marked it converged, no blockers): - N1: verified against the pinned vite@8 source that getAdditionalAllowedHosts pushes the resolved server.host into the allow-list — and this config sets server.host from HOST — so a bound HOST= IS auto-allowed under --dev. Narrowed the README caveat to the case that actually needs server.allowedHosts (reaching the dev server at a name OTHER than the one bound). - N2a: moved the dense defaultAllowedOrigins JSDoc off httpOrigin (where round-7's extraction stranded it) onto defaultAllowedOrigins; httpOrigin gets its own. - N2b: vite.config host fallback uses `|| "localhost"` (empty ⇒ unset), matching the port fallback on the same line — an ambient HOST="" no longer binds all interfaces for the non-serve commands. - N2c: test defaultAllowedOrigins("") — covers canonicalUrlHost's non-URL catch and pins the J1 no-`http://:PORT`-garbage property. - N2d: resolveSandboxPort now shares the strict ^\d+$ + <=65535 validation, so a partial-parse (6274abc) or out-of-range (70000) sandbox port can't crash the boot with ERR_SOCKET_BAD_PORT. Tests added. - N2e: comment notes only http(s)/ws(s) origins are supported (extension schemes canonicalize to "null" and are dropped). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 2 +- clients/web/server/sandbox-controller.ts | 31 +++++--- clients/web/server/web-server-config.ts | 75 ++++++++++--------- .../server/sandbox-controller.test.ts | 11 +++ .../server/web-server-config.test.ts | 12 +++ clients/web/vite.config.ts | 4 +- 6 files changed, 87 insertions(+), 48 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 43af40e53..e349b0d2c 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -173,7 +173,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. - **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it 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: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. -The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts but **rejects a named hostname** unless you add it. So a bare `HOST=` works out of the box for the prod server but needs `server.allowedHosts` configured (or a specific IP) under `--dev` — for network hosting, prefer the prod server (`mcp-inspector --web`) or bind a specific IP. +The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. **MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index a25d38958..115202576 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -71,21 +71,30 @@ 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 in either falls through rather than crashing the boot. */ 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 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 ( + parseListenPort(process.env.MCP_SANDBOX_PORT) ?? + parseListenPort(process.env.SERVER_PORT) ?? + 0 + ); } export function createSandboxController( diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 9fde70e9f..ecebf8617 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -217,37 +217,9 @@ export interface BuildWebServerConfigOptions { const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); /** - * 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. - * - * 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 — lowercased and, for an IPv6 literal, bracketed, so it matches - * the `Origin` header the browser actually sends. - * - * 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. + * 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}`; @@ -281,6 +253,37 @@ function canonicalUrlHost(host: string): string { } } +/** + * 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, @@ -368,10 +371,12 @@ export function buildWebServerConfig( 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 `file:`, `about:`, `javascript:`, `data:`). 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 must carry a scheme. + // (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. Only http(s)/ws(s) origins + // are supported (the app is served over http(s)); entries need a scheme. 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 diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index bd6078456..4d76c178c 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -111,6 +111,17 @@ describe("resolveSandboxPort", () => { process.env.MCP_SANDBOX_PORT = "-1"; expect(resolveSandboxPort()).toBe(0); }); + + it("rejects a partial-parse value (6274abc) rather than binding 6274", () => { + process.env.MCP_SANDBOX_PORT = "6274abc"; + process.env.SERVER_PORT = "9100"; + expect(resolveSandboxPort()).toBe(9100); + }); + + it("rejects an out-of-range value (70000) so it can't crash listen", () => { + process.env.MCP_SANDBOX_PORT = "70000"; + expect(resolveSandboxPort()).toBe(0); + }); }); describe("createSandboxController", () => { diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 28da7356a..d70af5043 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -380,6 +380,18 @@ describe("defaultAllowedOrigins", () => { ]); }); + it("handles an empty host (canonicalUrlHost's non-URL fallback) as the wildcard", () => { + // `new URL("http://")` throws, so canonicalUrlHost falls back to "" — which + // isAllInterfacesHost treats as the wildcard. No `http://:PORT` garbage. + expect(defaultAllowedOrigins("", 8123)).toEqual([ + "http://localhost:8123", + "http://127.0.0.1:8123", + "http://[::1]:8123", + "http://0.0.0.0:8123", + "http://[::]:8123", + ]); + }); + // Any wildcard spelling yields the loopback trio + the CANONICAL wildcard pair // (0.0.0.0 / [::]) — not the typed spelling, which the browser canonicalizes // away (HOST=0 / 0x0 / 0.0.0 all send http://0.0.0.0:PORT; ::0 sends [::]). diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 08cd82a35..3b99d672b 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -175,7 +175,9 @@ export default defineConfig(({ command }) => { port: devConfig?.port ?? parseInt(process.env.CLIENT_PORT?.trim() || "6274", 10), - host: devConfig?.hostname ?? process.env.HOST ?? "localhost", + // `|| "localhost"` (empty ⇒ unset) like the port above — an ambient + // HOST="" is Node's all-interfaces address, the one value this guards. + host: devConfig?.hostname ?? (process.env.HOST?.trim() || "localhost"), strictPort: true, fs: { allow: [path.resolve(dirname, "../..")], From e66c24dad19b97b5d9a0104bc5208b0b2232e245 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 18:38:35 -0400 Subject: [PATCH 14/31] =?UTF-8?q?review:=20round-12=20fixes=20=E2=80=94=20?= =?UTF-8?q?strictPort=20parity=20on=20the=20launcher=20dev=20path,=20unmap?= =?UTF-8?q?=20IPv4=20loopback=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-12 review of #1796 (converged, no blockers): - U1: start-vite-dev-server.ts (the `mcp-inspector --web --dev` bind point, which runs configFile:false and reproduces vite.config's server block) was missing strictPort:true, so a busy CLIENT_PORT would drift the bound port while the origin allow-list + sandbox CSP stay on config.port — 403 every connect, CSP- block the Apps iframe, and the banner advertises the unusable port. Added it so all three bind points agree. - U2: canonicalUrlHost unmaps an IPv4-mapped IPv6 host (::ffff:127.0.0.1 → 127.0.0.1, the address the socket actually answers on), so it lands in the loopback trio instead of a single unmatchable [::ffff:7f00:1] entry. Mirrors the bind guard already folding the mapped wildcard. Last "bind works, origin can't match" spelling — closed. - U3a: drop the now-dead "::1" from LOOPBACK_HOSTNAMES (canonicalUrlHost always brackets IPv6). U3b: warn on a set-but-invalid MCP_SANDBOX_PORT so the fall-through isn't silent. U3c: README notes the specific-IP origin is canonicalized. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 2 +- clients/web/server/sandbox-controller.ts | 16 +++++---- clients/web/server/start-vite-dev-server.ts | 6 ++++ clients/web/server/web-server-config.ts | 33 +++++++++++++++---- .../server/sandbox-controller.test.ts | 4 +++ .../server/web-server-config.test.ts | 30 +++++++++++------ 6 files changed, 67 insertions(+), 24 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index e349b0d2c..8d1fb1a6f 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -169,7 +169,7 @@ The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOri The guard blocks only the **wildcard** all-interfaces addresses. Binding a **specific** IP or hostname is allowed with no opt-in — that's a single, deliberate exposure, unlike the wildcard which binds every interface at once (the pattern DNS-rebinding exploits). To serve the Inspector on a LAN or the internet: -- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. (An IPv6 bind host is bracketed — `http://[2001:db8::1]:PORT` — and the port is dropped when it's the http default `:80`, matching what the browser sends.) +- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. (The host is canonicalized the way a browser is — so `HOST=127.1` advertises `http://127.0.0.1:PORT`, an IPv6 bind host is bracketed as `http://[2001:db8::1]:PORT`, and the port is dropped when it's the http default `:80` — matching what the browser sends.) - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. - **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it 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: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 115202576..320b7f3d8 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -87,14 +87,18 @@ function parseListenPort(raw: string | undefined): number | undefined { /** * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic). - * An invalid value in either falls through rather than crashing the boot. + * 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 { - return ( - parseListenPort(process.env.MCP_SANDBOX_PORT) ?? - parseListenPort(process.env.SERVER_PORT) ?? - 0 - ); + 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) ?? 0; } export function createSandboxController( diff --git a/clients/web/server/start-vite-dev-server.ts b/clients/web/server/start-vite-dev-server.ts index 7037a85cb..91a7fab70 100644 --- a/clients/web/server/start-vite-dev-server.ts +++ b/clients/web/server/start-vite-dev-server.ts @@ -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: { diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index ecebf8617..cc0631e8e 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -213,8 +213,10 @@ export interface BuildWebServerConfigOptions { * 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", "[::1]"]); +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 @@ -234,20 +236,37 @@ function loopbackOrigins(port: number): string[] { ]; } +/** + * Unmap an IPv4-mapped IPv6 host (`[::ffff:7f00:1]`) to its dotted IPv4 form + * (`127.0.0.1`) — the address the socket actually answers on (a + * `::ffff:127.0.0.1` bind is reachable at `127.0.0.1`, not `::1`). Other hosts + * pass through. Mirrors the bind guard, which already folds the mapped + * *wildcard* (`::ffff:0:0`) into `ALL_INTERFACES_LITERALS`. + */ +function unmapIpv4MappedHost(host: string): string { + const bare = host.replace(/^\[(.*)\]$/, "$1"); + const m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(bare); + if (!m) return host; + const hi = parseInt(m[1], 16); + const lo = parseInt(m[2], 16); + return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; +} + /** * Canonicalize a bind host the way a browser does before building `Origin`, so * the allow-list entry matches the header. Non-canonical spellings of the same * address — `127.1` / `0x7f.0.0.1` / `2130706433` all mean `127.0.0.1`, - * `0:0:0:0:0:0:0:1` / `::0001` mean `::1` — otherwise both miss the - * {@link LOOPBACK_HOSTNAMES} lookup (losing the loopback-trio expansion for an - * address that IS loopback) and emit an origin the browser can never send. - * `new URL().hostname` returns the URL-ready form (bracketed for IPv6); falls - * back to the formatted input if it isn't a parseable URL host. + * `0:0:0:0:0:0:0:1` / `::0001` mean `::1`, `::ffff:127.0.0.1` means `127.0.0.1` + * (the address it binds) — otherwise miss the {@link LOOPBACK_HOSTNAMES} lookup + * (losing the loopback-trio expansion for an address that IS loopback) and emit + * an origin the browser can never send. `new URL().hostname` returns the + * URL-ready form (bracketed for IPv6); falls back to the formatted input if it + * isn't a parseable URL host. */ function canonicalUrlHost(host: string): string { const formatted = formatHostForUrl(host.trim().toLowerCase()); try { - return new URL(`http://${formatted}`).hostname; + return unmapIpv4MappedHost(new URL(`http://${formatted}`).hostname); } catch { return formatted; } diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 4d76c178c..19e668f2c 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -53,6 +53,7 @@ describe("sandboxFrameAncestors", () => { describe("resolveSandboxPort", () => { let envSnapshot: { mcp?: string; server?: string }; + let warnSpy: ReturnType; beforeEach(() => { envSnapshot = { @@ -61,9 +62,12 @@ describe("resolveSandboxPort", () => { }; delete process.env.MCP_SANDBOX_PORT; delete process.env.SERVER_PORT; + // A set-but-invalid MCP_SANDBOX_PORT warns; keep it off the test console. + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { + warnSpy.mockRestore(); if (envSnapshot.mcp === undefined) delete process.env.MCP_SANDBOX_PORT; else process.env.MCP_SANDBOX_PORT = envSnapshot.mcp; if (envSnapshot.server === undefined) delete process.env.SERVER_PORT; diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index d70af5043..37ef20d57 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -361,16 +361,26 @@ describe("defaultAllowedOrigins", () => { // A non-canonical spelling of a loopback address is canonicalized (the way the // browser canonicalizes it into `Origin`), so it's recognized as loopback and // gets the trio — not a single unmatchable entry. - it.each(["127.1", "0x7f.0.0.1", "2130706433", "0:0:0:0:0:0:0:1", "::0001"])( - "canonicalizes the loopback spelling %j and returns the trio", - (host) => { - expect(defaultAllowedOrigins(host, 6274)).toEqual([ - "http://localhost:6274", - "http://127.0.0.1:6274", - "http://[::1]:6274", - ]); - }, - ); + it.each([ + "127.1", + "0x7f.0.0.1", + "2130706433", + "0:0:0:0:0:0:0:1", + "::0001", + "::ffff:127.0.0.1", // IPv4-mapped loopback — the socket answers on 127.0.0.1 + ])("canonicalizes the loopback spelling %j and returns the trio", (host) => { + expect(defaultAllowedOrigins(host, 6274)).toEqual([ + "http://localhost:6274", + "http://127.0.0.1:6274", + "http://[::1]:6274", + ]); + }); + + it("unmaps an IPv4-mapped non-loopback host to its dotted form", () => { + expect(defaultAllowedOrigins("::ffff:192.168.1.50", 6274)).toEqual([ + "http://192.168.1.50:6274", + ]); + }); it("keeps a distinct non-loopback address (127.0.0.2) as a single origin", () => { // 127.0.0.2 is canonical and a bind there doesn't serve 127.0.0.1, so the From 08896805d3cd4048e05ba65ac790485fc80a4b57 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 18:50:15 -0400 Subject: [PATCH 15/31] =?UTF-8?q?review:=20round-13=20fixes=20=E2=80=94=20?= =?UTF-8?q?doc-maintenance=20for=20the=20new=20file/folder,=20normalize=20?= =?UTF-8?q?both=20branches=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-13 review of #1796 (converged; W1 is the AGENTS.md-mandated doc-maintenance rule): - W1: add resolve-bind-host.ts to the clients/web/server file listings (AGENTS.md + clients/web/README.md) and core/node/ to the core/ tree (AGENTS.md + root README) — core/node/hostUrl.ts is now a shared surface consumed from five call sites, in a folder the docs didn't mention. - W2a: feed the canonicalized host to BOTH branches of defaultAllowedOrigins (isAllInterfacesHost(h), not the raw hostname) so one normalized value drives every branch — structural, not incidental. - W2b: note isAllZeroIpv4 is called with any normalized host (may be an IPv6 literal) and that parseAddressPart's [0-9]+ does double duty for octal. - W2c: add bare octal/hex all-zero cases (00000000000, 0x00000000) to the wildcard it.each. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 8 ++++++++ README.md | 1 + clients/web/README.md | 3 ++- clients/web/server/resolve-bind-host.ts | 10 +++++++--- clients/web/server/web-server-config.ts | 5 ++++- .../test/integration/server/resolve-bind-host.test.ts | 2 ++ 6 files changed, 24 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe51737a8..406c73084 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,9 @@ inspector/ │ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server), │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions), +│ │ │ # resolve-bind-host.ts (shared bind-host guard: refuses an +│ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES; +│ │ │ # used by both bind points — web-server-config.ts + vite.config.ts — #1795), │ │ │ # browser-externalized-builtin-gate.ts (build-gate logic that fails │ │ │ # `vite build` on a browser-externalized Node built-in — #1769) │ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) @@ -50,6 +53,11 @@ inspector/ │ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch │ │ │ └── node/ # Hono-based remote server backend (used by remote/ above) │ │ └── state/ # InspectorClient state stores consumed by core/react/ +│ ├── node/ # Node-only shared helpers: version.ts (readInspectorVersion, +│ │ # walks to the root package.json), hostUrl.ts (formatHostForUrl — +│ │ # brackets IPv6 for a URL authority; used by the web origin +│ │ # allow-list/banner/sandbox URL, the CLI deep-link, and +│ │ # core/auth/node redirect URLs — #1795) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests. diff --git a/README.md b/README.md index 20bb80312..ceb494912 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ inspector/ │ ├── json/ # JSON + parameter/argument conversion utilities │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import +│ ├── node/ # Node-only shared helpers: version reader, hostUrl (URL host formatter) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests diff --git a/clients/web/README.md b/clients/web/README.md index 8d1fb1a6f..8d1efabc1 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -16,7 +16,8 @@ The `server/` directory holds the Node-only backend: - **`vite-hono-plugin.ts`** — mounts the Hono `/api/*` middleware onto the Vite dev server (so `npm run dev` has a live backend). - **`server.ts`** — the standalone Hono production server (serves `dist/` + `/api/*`). - **`run-web.ts`** / **`start-vite-dev-server.ts`** — entry points the launcher calls for prod `--web` and `--web --dev`. -- **`web-server-config.ts`** — env parsing, the `GET /api/config` payload, the startup banner. +- **`web-server-config.ts`** — env parsing, the `GET /api/config` payload, the startup banner, the default origin allow-list. +- **`resolve-bind-host.ts`** — the shared bind-host guard (refuses an all-interfaces `HOST` unless `DANGEROUSLY_BIND_ALL_INTERFACES`), used by both bind points (`web-server-config.ts` + `vite.config.ts`); see [Host binding & the origin allow-list](#host-binding--the-origin-allow-list). - **`inject-auth-token.ts`** — embeds the API token into the served `index.html` (see [Auth token](#auth-token)). - **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions. - **`browser-externalized-builtin-gate.ts`** — Vite-agnostic build-gate logic that fails `vite build` when a Node built-in reaches the browser bundle (#1769); the thin Vite plugin wiring lives in `vite.config.ts`. It sits under `server/` (rather than `src/`) as the home for Node-only, build-time tooling — it's imported by the Vite config, never by the browser — alongside the other `vite-*` config helpers here. diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index b5f8a7d22..88325be66 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -57,9 +57,13 @@ function parseAddressPart(part: string): number { * True when `value` is an all-zero IPv4 address in any legacy spelling the OS * still binds as the `0.0.0.0` wildcard: the bare integer `0`, `0x0`, dotted * `0.0.0.0`, `000.000.000.000`, `0x0.0.0.0`, and the short forms `0.0` / `0.0.0` - * (Node/`inet_aton` accept 1–4 parts). Guards the near-miss bypasses of the - * literal set above. The `> 4` reject is intentional — 1–3 parts are valid - * `inet_aton` spellings, so this is deliberately NOT `parts.length === 4`. + * (Node/`inet_aton` accept 1–4 parts; `parseAddressPart`'s `[0-9]+` branch does + * double duty for decimal and `0`-prefixed octal). Guards the near-miss + * bypasses of the literal set above. The `> 4` reject is intentional — 1–3 + * parts are valid `inet_aton` spellings, so this is deliberately NOT + * `parts.length === 4`. Called from {@link isAllInterfacesHost} with any + * normalized host, so it may receive an IPv6 literal (`::1`) — the dot-split + * yields a single non-numeric part → `NaN` → `false`, which is correct. */ function isAllZeroIpv4(value: string): boolean { const parts = value.split("."); diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index cc0631e8e..5934e5373 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -307,11 +307,14 @@ export function defaultAllowedOrigins( hostname: string, port: number, ): string[] { + // One normalized value drives every branch (so they can't disagree about the + // same host): the loopback lookup, the wildcard check, and the emitted origin + // all read `h`. const h = canonicalUrlHost(hostname); if (LOOPBACK_HOSTNAMES.has(h)) { return loopbackOrigins(port); } - if (isAllInterfacesHost(hostname)) { + if (isAllInterfacesHost(h)) { return [ ...loopbackOrigins(port), httpOrigin("0.0.0.0", port), diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 323ba8f95..8ca14976b 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -32,6 +32,8 @@ describe("isAllInterfacesHost", () => { "000.000.000.000", "0.0", // short inet_aton form (1–3 parts) still binds the wildcard "0.0.0", + "00000000000", // bare octal zero — parseAddressPart's [0-9]+ handles octal + "0x00000000", // bare hex zero ])("flags the all-interfaces host %j", (host) => { expect(isAllInterfacesHost(host)).toBe(true); }); From 420a3cc1ff2ae959203996ac242a42c1efeca646 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:01:41 -0400 Subject: [PATCH 16/31] =?UTF-8?q?review:=20round-14=20fix=20=E2=80=94=20re?= =?UTF-8?q?store=20banner=20=E2=8A=86=20allow-list=20for=20mapped-IPv4,=20?= =?UTF-8?q?unify=20bracket=20strip=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-14 review of #1796 (converged): - Y1 (regression from U2): the allow-list unmapped an IPv4-mapped IPv6 host but the banner and sandbox URL didn't, so `banner ⊆ allowedOrigins` broke for HOST=::ffff:127.0.0.1 (advertised URL's origin wasn't allow-listed → 403 on the advertised path, CSP-blocked Apps). Moved canonicalUrlHost + unmapIpv4MappedHost to core/node/hostUrl.ts (the shared host-formatting home — avoids a circular import, since web-server-config already imports resolveSandboxPort from sandbox-controller) and made printServerBanner and the sandbox URL canonicalize through it, so banner ⊆ allowedOrigins holds by construction. Tests assert both. - Y2a: unified the three bracket-strip regexes into a single exported stripBrackets in hostUrl.ts (was `/^\[(.+)\]$/` in resolve-bind-host vs `/^\[(.*)\]$/` elsewhere); documented the unmap step's intentional divergence from browser canonicalization in canonicalUrlHost's doc. - Y2b: SERVER_PORT-no-warn left as documented (the dedicated knob MCP_SANDBOX_PORT warns). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/server/resolve-bind-host.ts | 12 ++--- clients/web/server/sandbox-controller.ts | 8 +-- clients/web/server/web-server-config.ts | 51 ++++--------------- .../web/src/test/core/node/hostUrl.test.ts | 42 ++++++++++++++- .../server/sandbox-controller.test.ts | 15 ++++++ .../server/web-server-config.test.ts | 11 ++++ core/node/hostUrl.ts | 50 +++++++++++++++++- 7 files changed, 133 insertions(+), 56 deletions(-) diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 88325be66..00005e465 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -8,6 +8,7 @@ */ import { isIPv6 } from "node:net"; +import { 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"; @@ -71,16 +72,9 @@ function isAllZeroIpv4(value: string): boolean { return parts.every((part) => parseAddressPart(part) === 0); } -/** Strip a single surrounding `[...]` pair from a bracketed IPv6 literal; other hosts pass through. */ -function stripIpv6Brackets(host: string): string { - return host.replace(/^\[(.+)\]$/, "$1"); -} - /** True when `host` binds all interfaces rather than loopback only. */ export function isAllInterfacesHost(host: string): boolean { - const normalized = canonicalizeIpv6( - stripIpv6Brackets(host.trim().toLowerCase()), - ); + const normalized = canonicalizeIpv6(stripBrackets(host.trim().toLowerCase())); return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); } @@ -119,5 +113,5 @@ export function resolveBindHostname( `${BIND_ALL_INTERFACES_ENV}=true.`, ); } - return stripIpv6Brackets(host); + return stripBrackets(host); } diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 320b7f3d8..dd0fccbe3 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -7,7 +7,7 @@ import { createServer, type Server } from "node:http"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { formatHostForUrl } from "../../../core/node/hostUrl.ts"; +import { canonicalUrlHost } from "../../../core/node/hostUrl.ts"; import { isAllInterfacesHost } from "./resolve-bind-host.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -196,10 +196,12 @@ export function createSandboxController( // 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. + // 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). const urlHost = isAllInterfacesHost(host) ? "localhost" - : formatHostForUrl(host); + : canonicalUrlHost(host); sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`; settle({ port: actualPort, url: sandboxUrl }); }); diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 5934e5373..bac6f40b8 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -20,7 +20,7 @@ import { isAllInterfacesHost, resolveBindHostname, } from "./resolve-bind-host.js"; -import { formatHostForUrl } from "../../../core/node/hostUrl.ts"; +import { canonicalUrlHost } 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 @@ -168,13 +168,16 @@ export function printServerBanner( resolvedToken: string, sandboxUrl: string | undefined, ): string { - // Advertise `localhost` for a wildcard bind: `http://0.0.0.0:PORT` is an - // awkward URL to click, so point the user at a loopback address that's both - // reachable and in the default allow-list. Uses httpOrigin so the banner and - // the origin allow-list agree on the default-port form (both drop :80). + // 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). const bannerHost = isAllInterfacesHost(config.hostname) ? "localhost" - : formatHostForUrl(config.hostname); + : canonicalUrlHost(config.hostname); const baseUrl = httpOrigin(bannerHost, actualPort); const url = config.dangerouslyOmitAuth || !resolvedToken @@ -236,42 +239,6 @@ function loopbackOrigins(port: number): string[] { ]; } -/** - * Unmap an IPv4-mapped IPv6 host (`[::ffff:7f00:1]`) to its dotted IPv4 form - * (`127.0.0.1`) — the address the socket actually answers on (a - * `::ffff:127.0.0.1` bind is reachable at `127.0.0.1`, not `::1`). Other hosts - * pass through. Mirrors the bind guard, which already folds the mapped - * *wildcard* (`::ffff:0:0`) into `ALL_INTERFACES_LITERALS`. - */ -function unmapIpv4MappedHost(host: string): string { - const bare = host.replace(/^\[(.*)\]$/, "$1"); - const m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(bare); - if (!m) return host; - const hi = parseInt(m[1], 16); - const lo = parseInt(m[2], 16); - return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; -} - -/** - * Canonicalize a bind host the way a browser does before building `Origin`, so - * the allow-list entry matches the header. Non-canonical spellings of the same - * address — `127.1` / `0x7f.0.0.1` / `2130706433` all mean `127.0.0.1`, - * `0:0:0:0:0:0:0:1` / `::0001` mean `::1`, `::ffff:127.0.0.1` means `127.0.0.1` - * (the address it binds) — otherwise miss the {@link LOOPBACK_HOSTNAMES} lookup - * (losing the loopback-trio expansion for an address that IS loopback) and emit - * an origin the browser can never send. `new URL().hostname` returns the - * URL-ready form (bracketed for IPv6); falls back to the formatted input if it - * isn't a parseable URL host. - */ -function canonicalUrlHost(host: string): string { - const formatted = formatHostForUrl(host.trim().toLowerCase()); - try { - return unmapIpv4MappedHost(new URL(`http://${formatted}`).hostname); - } catch { - return formatted; - } -} - /** * The default allowed-origins list for a given bind host/port. * diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index d248e08cb..a2a6fa262 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { formatHostForUrl } from "@inspector/core/node/hostUrl.js"; +import { + canonicalUrlHost, + formatHostForUrl, + stripBrackets, +} from "@inspector/core/node/hostUrl.js"; describe("formatHostForUrl", () => { it.each([ @@ -25,3 +29,39 @@ describe("formatHostForUrl", () => { expect(formatHostForUrl("localhost:6274")).toBe("localhost:6274"); }); }); + +describe("stripBrackets", () => { + it.each([ + ["[::1]", "::1"], + ["[fe80::1%eth0]", "fe80::1%eth0"], + ["[]", ""], // zero-or-more: an empty bracket pair reduces to "" + ["127.0.0.1", "127.0.0.1"], + ])("strips a surrounding bracket pair from %j", (host, expected) => { + expect(stripBrackets(host)).toBe(expected); + }); +}); + +describe("canonicalUrlHost", () => { + it.each([ + ["127.1", "127.0.0.1"], + ["0x7f.0.0.1", "127.0.0.1"], + ["2130706433", "127.0.0.1"], + ["0:0:0:0:0:0:0:1", "[::1]"], + ["::0001", "[::1]"], + ["LOCALHOST", "localhost"], + ["Example.COM", "example.com"], + ["fe80::1", "[fe80::1]"], + // IPv4-mapped IPv6 → the dotted IPv4 the socket answers on (intentional + // divergence from browser canonicalization). + ["::ffff:127.0.0.1", "127.0.0.1"], + ["::ffff:192.168.1.50", "192.168.1.50"], + // A distinct address is unchanged. + ["127.0.0.2", "127.0.0.2"], + ])("canonicalizes %j to %j", (host, expected) => { + expect(canonicalUrlHost(host)).toBe(expected); + }); + + it("falls back to the formatted value when the host isn't a parseable URL", () => { + expect(canonicalUrlHost("")).toBe(""); + }); +}); diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 19e668f2c..99bebaec1 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -197,6 +197,21 @@ describe("createSandboxController", () => { } }); + it("uses the canonical (unmapped) host in the sandbox URL", async () => { + // Same canonicalization as the origin allow-list, so the served sandbox URL + // is reachable — ::ffff:127.0.0.1 answers at 127.0.0.1, not [::ffff:...]. + const controller = createSandboxController({ + port: 0, + host: "::ffff:127.0.0.1", + }); + try { + const { url, port } = await controller.start(); + expect(url).toBe(`http://127.0.0.1:${port}/sandbox`); + } finally { + await controller.close(); + } + }); + it("returns 404 for paths other than /sandbox", async () => { const controller = createSandboxController({ port: 0 }); try { diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 37ef20d57..5436b8fea 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -634,6 +634,17 @@ describe("printServerBanner", () => { expect(url).toBe("http://localhost:6274"); }); + it("advertises the canonical (unmapped) host so banner ⊆ allowedOrigins", () => { + // An IPv4-mapped bind host: the allow-list emits the loopback trio (which + // includes http://127.0.0.1), so the banner must advertise a member of it, + // not the [::ffff:127.0.0.1] form (whose Origin is [::ffff:7f00:1]). + const cfg = baseConfig(); + cfg.hostname = "::ffff:127.0.0.1"; + const url = printServerBanner(cfg, 6274, "", undefined); + expect(url).toBe("http://127.0.0.1:6274"); + expect(defaultAllowedOrigins(cfg.hostname, 6274)).toContain(url); + }); + it("prints the sandbox URL when provided", () => { printServerBanner(baseConfig(), 6274, "tok", "http://sandbox:9999/sandbox"); expect( diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index ac85e56c1..4d6c9d33b 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -1,5 +1,10 @@ import { isIPv6 } from "node:net"; +/** Strip a single surrounding `[...]` pair from a bracketed IPv6 literal; other hosts pass through. */ +export function stripBrackets(host: string): string { + return host.replace(/^\[(.*)\]$/, "$1"); +} + /** * Format a bind host for use inside a URL authority. An IPv6 literal must be * bracketed (`http://[::1]:6274`); every other host — loopback names, IPv4, @@ -18,6 +23,49 @@ export function formatHostForUrl(host: string): string { // bracketed-with-zone input (`[fe80::1%eth0]`) still yields a valid URL host. // (A non-IPv6 value is returned as-given — this normalizes IPv6 literals, it // doesn't validate arbitrary hosts.) - const bare = h.replace(/^\[(.*)\]$/, "$1").split("%")[0]; + const bare = stripBrackets(h).split("%")[0]; return isIPv6(bare) ? `[${bare}]` : h; } + +/** + * Unmap an IPv4-mapped IPv6 host (`[::ffff:7f00:1]`) to its dotted IPv4 form + * (`127.0.0.1`) — the address the socket actually answers on (a + * `::ffff:127.0.0.1` bind is reachable at `127.0.0.1`, not `::1`). Other hosts + * pass through. Mirrors the bind guard, which folds the mapped *wildcard* + * (`::ffff:0:0`) into its all-interfaces set. + */ +function unmapIpv4MappedHost(host: string): string { + const m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec( + stripBrackets(host), + ); + if (!m) return host; + const hi = parseInt(m[1], 16); + const lo = parseInt(m[2], 16); + return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; +} + +/** + * Canonicalize a bind host the way a browser does before building `Origin`, so + * that the origin allow-list, the startup banner, and the sandbox URL all use + * the *same* form and can't disagree. Non-canonical spellings of the same + * address — `127.1` / `0x7f.0.0.1` / `2130706433` all mean `127.0.0.1`, + * `0:0:0:0:0:0:0:1` / `::0001` mean `::1` — otherwise produce hosts that miss + * the loopback lookup or don't match the header the browser sends. + * + * One step **intentionally diverges** from browser canonicalization: an + * IPv4-mapped IPv6 host is unmapped to its dotted IPv4 form (the address the + * socket answers on), where a browser would keep the mapped literal. Because the + * banner/sandbox URL are canonicalized through here too, `banner ⊆ allowedOrigins` + * holds by construction — the advertised URL is always an allow-listed origin. + * + * `new URL().hostname` returns the URL-ready form (bracketed for IPv6); falls + * back to the formatted input if it isn't a parseable URL host. + */ +export function canonicalUrlHost(host: string): string { + const formatted = formatHostForUrl(host.trim().toLowerCase()); + try { + return unmapIpv4MappedHost(new URL(`http://${formatted}`).hostname); + } catch { + return formatted; + } +} From 6ed9b79be11e6eaec6e6bcdea344e73cd7c4a755 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:13:11 -0400 Subject: [PATCH 17/31] =?UTF-8?q?review:=20round-15=20fixes=20=E2=80=94=20?= =?UTF-8?q?CLI=20deep-link=20canonicalization=20(last=20Y1=20site),=20doc/?= =?UTF-8?q?structure=20polish=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-15 review of #1796 (converged; bind guard closed): - Z1: the CLI handoff deep-link (cli.ts buildHandoff) was the last site formatting with formatHostForUrl instead of canonicalUrlHost, so HOST=::ffff:127.0.0.1 gave a deep link whose Origin isn't in the web allow-list (loopback trio) → autoConnect 403. Swapped to canonicalUrlHost (already exported from core/node/hostUrl, which cli.ts imports). banner ⊆ allowedOrigins now holds at all six call sites. CLI test asserts the mapped-host deep link is http://127.0.0.1:PORT. - Z2: updated the core/node/hostUrl structure-doc entries (AGENTS.md + root README) to its round-14 purpose (shared host normalization/canonicalization, not just a formatter). - Z3: printServerBanner + the sandbox URL compute `h = canonicalUrlHost(...)` once and test isAllInterfacesHost(h) — one normalized value drives predicate and value, matching defaultAllowedOrigins. - Z4: reworded the two sandbox comments that described the empty-allowedOrigins "origin check disabled" case H1 made unreachable from the real path. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 10 ++++++---- README.md | 2 +- clients/cli/__tests__/stored-auth.test.ts | 19 +++++++++++++++++++ clients/cli/src/cli.ts | 4 ++-- clients/web/server/sandbox-controller.ts | 19 ++++++++++++------- clients/web/server/web-server-config.ts | 8 ++++---- 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 406c73084..e13de48c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,10 +54,12 @@ inspector/ │ │ │ └── node/ # Hono-based remote server backend (used by remote/ above) │ │ └── state/ # InspectorClient state stores consumed by core/react/ │ ├── node/ # Node-only shared helpers: version.ts (readInspectorVersion, -│ │ # walks to the root package.json), hostUrl.ts (formatHostForUrl — -│ │ # brackets IPv6 for a URL authority; used by the web origin -│ │ # allow-list/banner/sandbox URL, the CLI deep-link, and -│ │ # core/auth/node redirect URLs — #1795) +│ │ # walks to the root package.json), hostUrl.ts (shared host +│ │ # normalization — formatHostForUrl brackets IPv6, canonicalUrlHost +│ │ # canonicalizes a bind host the way a browser builds `Origin` so +│ │ # the web origin allow-list/banner/sandbox URL + the CLI deep-link +│ │ # agree; also stripBrackets. Used across clients/web/server, +│ │ # clients/cli, and core/auth/node — #1795) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests. diff --git a/README.md b/README.md index ceb494912..96f4a074d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ inspector/ │ ├── json/ # JSON + parameter/argument conversion utilities │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import -│ ├── node/ # Node-only shared helpers: version reader, hostUrl (URL host formatter) +│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalization: format + canonicalize) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 24c87c49e..1cd08b4ea 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -580,6 +580,25 @@ describe("--print-handoff", () => { expect(out.apiToken).toBe("tok123"); }); + it("canonicalizes the deep-link host so it matches the web allow-list (mapped IPv4)", async () => { + // HOST=::ffff:127.0.0.1 binds/serves 127.0.0.1; the deep link must advertise + // that canonical host, not [::ffff:127.0.0.1], or the web autoConnect POST + // 403s (the web allow-list emits the loopback trio for this HOST). + const result = await runCli( + ["--print-handoff", "--server-url", "https://x.example/mcp"], + { + env: { + MCP_INSPECTOR_API_TOKEN: "tok123", + HOST: "::ffff:127.0.0.1", + CLIENT_PORT: "16274", + }, + }, + ); + expectCliSuccess(result); + const out = JSON.parse(result.stdout) as { deepLink: string }; + expect(out.deepLink.startsWith("http://127.0.0.1:16274/?")).toBe(true); + }); + it("derives transport=sse for an SSE server (auto-detected from the /sse path)", async () => { const result = await runCli( ["--print-handoff", "--server-url", "https://x.example/sse"], diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index e8125979b..2f37335ce 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -23,7 +23,7 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; -import { formatHostForUrl } from "@inspector/core/node/hostUrl.js"; +import { canonicalUrlHost } from "@inspector/core/node/hostUrl.js"; import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js"; import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; import { runMethod } from "./handlers/run-method.js"; @@ -441,7 +441,7 @@ function buildHandoff( if (apiToken) params.set("autoConnect", apiToken); return { serverUrl: normalizedUrl, - deepLink: `http://${formatHostForUrl(host)}:${clientPort}/?${params.toString()}`, + deepLink: `http://${canonicalUrlHost(host)}:${clientPort}/?${params.toString()}`, portForwardCmd: `coder port-forward --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`, oauthStatePath: statePath, apiToken: apiToken ?? null, diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index dd0fccbe3..d02828c20 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -20,8 +20,10 @@ export interface SandboxControllerOptions { /** * 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). When - * empty or omitted, falls back to loopback-only. + * 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[]; } @@ -55,9 +57,10 @@ const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]]+$/i; * 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 (empty list, or the "origin check disabled" case), falls back to the - * loopback family so the sandbox still loads locally and the header can't be - * corrupted. + * 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)); @@ -199,9 +202,11 @@ export function createSandboxController( // 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). - const urlHost = isAllInterfacesHost(host) + // One normalized value drives both the predicate and the value. + const canonicalHost = canonicalUrlHost(host); + const urlHost = isAllInterfacesHost(canonicalHost) ? "localhost" - : canonicalUrlHost(host); + : canonicalHost; sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`; settle({ port: actualPort, url: sandboxUrl }); }); diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index bac6f40b8..3314c403d 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -174,10 +174,10 @@ export function printServerBanner( // 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). - const bannerHost = isAllInterfacesHost(config.hostname) - ? "localhost" - : canonicalUrlHost(config.hostname); + // keeps the banner and the list agreeing on the default-port form (both drop + // :80). One normalized value (`h`) drives both the predicate and the value. + const h = canonicalUrlHost(config.hostname); + const bannerHost = isAllInterfacesHost(h) ? "localhost" : h; const baseUrl = httpOrigin(bannerHost, actualPort); const url = config.dangerouslyOmitAuth || !resolvedToken From ecbb91bbe65f6d28fd41d13081b53acaf2e37913 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:19:16 -0400 Subject: [PATCH 18/31] =?UTF-8?q?review:=20round-16=20doc=20fixes=20?= =?UTF-8?q?=E2=80=94=20stale=20origin-guard=20advice,=20mixed-content=20Ap?= =?UTF-8?q?ps=20caveat=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-16 review of #1796 (converged — ship it; both items are docs): - AA1: docs/mcp-app-review.md's SSH-forward recipe said "use 127.0.0.1, not localhost — the guard checks the literal Origin against the configured HOST." Stale after #1795: defaultAllowedOrigins emits the loopback trio, so any of localhost / 127.0.0.1 / [::1] is accepted. Reworded to match; kept the still-correct same-port bullet. (AGENTS.md doc-maintenance rule.) - AA2: added the mixed-content MCP Apps caveat to the web README — the sandbox URL is always http://, so behind TLS an https:// app page blocks the http://…/sandbox iframe as mixed content and Apps can't render. (AA3 — server.ts EADDRINUSE using formatHostForUrl not canonicalUrlHost — left as-is per review: it names the host you asked to bind, which is what you want when diagnosing a collision.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 2 +- docs/mcp-app-review.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 8d1efabc1..3af9bf81e 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -176,7 +176,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. -**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. +**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. diff --git a/docs/mcp-app-review.md b/docs/mcp-app-review.md index c6ca251bd..a118f730c 100644 --- a/docs/mcp-app-review.md +++ b/docs/mcp-app-review.md @@ -168,8 +168,10 @@ ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally. -- Use `127.0.0.1`, **not** `localhost` — the backend's origin guard checks the - literal `Origin` header against the configured `HOST`. +- Any of the three loopback forms works — `127.0.0.1`, `localhost`, or `[::1]`. + The default origin allow-list emits all three for a loopback bind (#1795), so + the guard accepts whichever the browser sends. (A **non-loopback** forward + target needs `ALLOWED_ORIGINS` set to that origin.) - Forward `:6275` (`MCP_SANDBOX_PORT`) as well: the Apps tab renders widgets in an iframe served from that second origin; without it the App frame stays blank. From 9b3bf449495c325c8e2dc9046d7138034e5c3251 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:23:31 -0400 Subject: [PATCH 19/31] =?UTF-8?q?review:=20round-17=20doc=20fix=20?= =?UTF-8?q?=E2=80=94=20don't=20recommend=20[::1]=20for=20MCP=20Apps=20revi?= =?UTF-8?q?ew=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address AB1 of the round-17 review of #1796: the round-16 AA1 rewrite told MCP Apps reviewers "any of the three loopback forms works — 127.0.0.1, localhost, or [::1]", but [::1] is the one form that CSP-blocks the App sandbox iframe (a bracketed IPv6 literal isn't a valid frame-ancestors host-source — the F2 finding, verified in headless Chromium). Narrowed the bullet to 127.0.0.1 / localhost (both accepted by the default loopback trio, so the part AA1 fixed stands) and call out that a bare http://[::1]:… origin connects but can't render Apps, cross-referencing the web README caveat. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- docs/mcp-app-review.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/mcp-app-review.md b/docs/mcp-app-review.md index a118f730c..7a0acbe00 100644 --- a/docs/mcp-app-review.md +++ b/docs/mcp-app-review.md @@ -168,10 +168,13 @@ ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally. -- Any of the three loopback forms works — `127.0.0.1`, `localhost`, or `[::1]`. - The default origin allow-list emits all three for a loopback bind (#1795), so - the guard accepts whichever the browser sends. (A **non-loopback** forward - target needs `ALLOWED_ORIGINS` set to that origin.) +- Use `127.0.0.1` or `localhost` — the default origin allow-list emits both (and + `[::1]`) for a loopback bind (#1795), so the guard accepts either, whichever + the browser sends. **Do not** use a bare `http://[::1]:…` URL for App review: + it connects, but a bracketed IPv6 literal isn't a valid CSP `frame-ancestors` + source, so the App sandbox iframe is CSP-blocked and the widget never renders + (see the MCP Apps caveat in [`clients/web/README.md`](../clients/web/README.md#host-binding--the-origin-allow-list)). + A **non-loopback** forward target needs `ALLOWED_ORIGINS` set to that origin. - Forward `:6275` (`MCP_SANDBOX_PORT`) as well: the Apps tab renders widgets in an iframe served from that second origin; without it the App frame stays blank. From f18ee369f3bbdd6555aa1453f0c9e76c533fc6df Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:36:30 -0400 Subject: [PATCH 20/31] =?UTF-8?q?review:=20round-18=20fix=20=E2=80=94=20mo?= =?UTF-8?q?ve=20isAllInterfacesHost=20to=20core/node,=20wildcard=E2=86=92l?= =?UTF-8?q?ocalhost=20in=20CLI=20deep-link=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address AC1 of the round-18 review of #1796 (converged; AC2/AC3 acknowledged): - AC1: the CLI handoff deep-link canonicalized the host (round 15) but didn't do the wildcard→localhost substitution the banner/sandbox URL do, so HOST=0.0.0.0 gave an awkward http://0.0.0.0:PORT deep link (allow-listed, so it connects, but not a nice URL to hand a human). Moved isAllInterfacesHost + its pure helpers (canonicalizeIpv6, isAllZeroIpv4, parseAddressPart, ALL_INTERFACES_LITERALS) from clients/web/server/resolve-bind-host.ts into core/node/hostUrl.ts — the correct home for a pure host predicate, alongside canonicalUrlHost (same consolidation canonicalUrlHost got in round 14) — so the CLI can import it. buildHandoff now does isAllInterfacesHost(host) ? "localhost" : canonicalUrlHost(host). All four consumers (guard, banner, sandbox, CLI) share one predicate. Moved the isAllInterfacesHost tests to hostUrl.test.ts; added a CLI test asserting the wildcard deep link is http://localhost:PORT. AC2 (CLI reads CLIENT_PORT raw): left as-is — the same env fails the web server outright (buildWebServerConfig throws), so a handoff pointing at it is moot. AC3 (inert start()-twice NaN + broader CSP fallback): pre-existing/inert, unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/__tests__/stored-auth.test.ts | 19 +++++ clients/cli/src/cli.ts | 14 +++- clients/web/server/resolve-bind-host.ts | 71 ++----------------- clients/web/server/sandbox-controller.ts | 6 +- clients/web/server/web-server-config.ts | 6 +- .../web/src/test/core/node/hostUrl.test.ts | 52 ++++++++++++++ .../server/resolve-bind-host.test.ts | 52 -------------- core/node/hostUrl.ts | 68 ++++++++++++++++++ 8 files changed, 162 insertions(+), 126 deletions(-) diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 1cd08b4ea..0f389e181 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -599,6 +599,25 @@ describe("--print-handoff", () => { expect(out.deepLink.startsWith("http://127.0.0.1:16274/?")).toBe(true); }); + it("advertises localhost in the deep link for a wildcard HOST", async () => { + // 0.0.0.0 is allow-listed so it connects, but the deep link is handed to a + // human — advertise localhost like the web banner does. + const result = await runCli( + ["--print-handoff", "--server-url", "https://x.example/mcp"], + { + env: { + MCP_INSPECTOR_API_TOKEN: "tok123", + HOST: "0.0.0.0", + DANGEROUSLY_BIND_ALL_INTERFACES: "true", + CLIENT_PORT: "16274", + }, + }, + ); + expectCliSuccess(result); + const out = JSON.parse(result.stdout) as { deepLink: string }; + expect(out.deepLink.startsWith("http://localhost:16274/?")).toBe(true); + }); + it("derives transport=sse for an SSE server (auto-detected from the /sse path)", async () => { const result = await runCli( ["--print-handoff", "--server-url", "https://x.example/sse"], diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 2f37335ce..00d3861f5 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -23,7 +23,10 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; -import { canonicalUrlHost } from "@inspector/core/node/hostUrl.js"; +import { + canonicalUrlHost, + isAllInterfacesHost, +} from "@inspector/core/node/hostUrl.js"; import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js"; import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; import { runMethod } from "./handlers/run-method.js"; @@ -423,6 +426,13 @@ function buildHandoff( transport: "sse" | "http" | "stdio" | undefined, ): McpResponse { const host = process.env.HOST || "127.0.0.1"; + // The deep link is a URL handed to a human, so advertise localhost for a + // wildcard bind (like the web banner/sandbox URL) rather than the awkward + // http://0.0.0.0 / http://[::] — both are allow-listed, but neither is a nice + // URL to click; otherwise use the canonical host so it matches the allow-list. + const linkHost = isAllInterfacesHost(host) + ? "localhost" + : canonicalUrlHost(host); const clientPort = process.env.CLIENT_PORT || "6274"; const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275"; // Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token @@ -441,7 +451,7 @@ function buildHandoff( if (apiToken) params.set("autoConnect", apiToken); return { serverUrl: normalizedUrl, - deepLink: `http://${canonicalUrlHost(host)}:${clientPort}/?${params.toString()}`, + deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`, portForwardCmd: `coder port-forward --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`, oauthStatePath: statePath, apiToken: apiToken ?? null, diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 00005e465..35081e0a3 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -7,77 +7,14 @@ * dev server) so both bind points enforce the same policy. */ -import { isIPv6 } from "node:net"; -import { stripBrackets } from "../../../core/node/hostUrl.ts"; +import { + 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"; -/** - * Canonical spellings of the all-interfaces (unspecified) address. Every IPv6 - * wildcard spelling (`::`, `::0`, `0:0:…:0`, `::0.0.0.0`, …) canonicalizes to - * `::` and the IPv4-mapped wildcard to `::ffff:0:0` (see {@link canonicalizeIpv6}), - * so those two entries cover the whole IPv6 family; `0.0.0.0` is the IPv4 - * wildcard and `""` is Node's `listen()` unspecified address. All bind *every* - * interface. {@link isAllInterfacesHost} additionally folds the legacy IPv4 - * spellings the OS resolver still binds as `0.0.0.0`. - */ -const ALL_INTERFACES_LITERALS = new Set(["", "0.0.0.0", "::", "::ffff:0:0"]); - -/** - * Canonicalize an IPv6 literal via the WHATWG URL serializer, which compresses - * zero-runs — so `::0`, `0::0`, `::0.0.0.0`, and the fully-expanded - * `0000:…:0000` all collapse to `::`, and `::ffff:0.0.0.0` → `::ffff:0:0`. This - * is what lets a small literal set catch every all-zero IPv6 spelling rather - * than only the handful written out. Non-IPv6 input passes through unchanged. - * - * The zone index (`%eth0`) is stripped first: `net.isIPv6` accepts it but - * `new URL()` rejects a zone id outright (even `%25`-encoded), so passing it - * through would throw. The zone is irrelevant to *which* address this is, so - * dropping it is correct for detection — `::%eth0` still canonicalizes to `::`. - * (The caller keeps the zone on the value it returns for `listen()`.) - */ -function canonicalizeIpv6(value: string): string { - if (!isIPv6(value)) return value; - const [address] = value.split("%"); - return new URL(`http://[${address}]`).hostname.slice(1, -1); -} - -/** - * Parse one dotted-address part (or a bare address) the way the C `inet_aton` - * resolver does — decimal, `0`-prefixed octal, and `0x`-prefixed hex are all - * accepted. Returns `NaN` for anything non-numeric (e.g. a hostname label). - */ -function parseAddressPart(part: string): number { - if (/^0x[0-9a-f]+$/.test(part)) return parseInt(part, 16); - if (/^[0-9]+$/.test(part)) return parseInt(part, 10); - return NaN; -} - -/** - * True when `value` is an all-zero IPv4 address in any legacy spelling the OS - * still binds as the `0.0.0.0` wildcard: the bare integer `0`, `0x0`, dotted - * `0.0.0.0`, `000.000.000.000`, `0x0.0.0.0`, and the short forms `0.0` / `0.0.0` - * (Node/`inet_aton` accept 1–4 parts; `parseAddressPart`'s `[0-9]+` branch does - * double duty for decimal and `0`-prefixed octal). Guards the near-miss - * bypasses of the literal set above. The `> 4` reject is intentional — 1–3 - * parts are valid `inet_aton` spellings, so this is deliberately NOT - * `parts.length === 4`. Called from {@link isAllInterfacesHost} with any - * normalized host, so it may receive an IPv6 literal (`::1`) — the dot-split - * yields a single non-numeric part → `NaN` → `false`, which is correct. - */ -function isAllZeroIpv4(value: string): boolean { - const parts = value.split("."); - if (parts.length > 4) return false; - return parts.every((part) => parseAddressPart(part) === 0); -} - -/** True when `host` binds all interfaces rather than loopback only. */ -export function isAllInterfacesHost(host: string): boolean { - const normalized = canonicalizeIpv6(stripBrackets(host.trim().toLowerCase())); - return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); -} - /** * An explicit, unambiguous opt-in. Unlike a bare `!!value` (which treats the * string `"false"` as truthy), only `"true"`/`"1"` (case-insensitive) enable diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index d02828c20..82e9794f7 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -7,8 +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 } from "../../../core/node/hostUrl.ts"; -import { isAllInterfacesHost } from "./resolve-bind-host.js"; +import { + canonicalUrlHost, + isAllInterfacesHost, +} from "../../../core/node/hostUrl.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 3314c403d..5909d295a 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -16,11 +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, - resolveBindHostname, -} from "./resolve-bind-host.js"; -import { canonicalUrlHost } from "../../../core/node/hostUrl.ts"; +} 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 diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index a2a6fa262..e41816c1f 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -2,9 +2,61 @@ import { describe, it, expect } from "vitest"; import { canonicalUrlHost, formatHostForUrl, + isAllInterfacesHost, stripBrackets, } from "@inspector/core/node/hostUrl.js"; +describe("isAllInterfacesHost", () => { + it.each([ + "0.0.0.0", + "::", + "", + " 0.0.0.0 ", + " :: ", + "[::]", + "0:0:0:0:0:0:0:0", + "::ffff:0.0.0.0", + "::ffff:0:0", + // IPv6 wildcard spellings that canonicalize to `::`. + "::0", + "0::0", + "::0.0.0.0", + "0:0::0", + "0000:0000:0000:0000:0000:0000:0000:0000", + // Zone-scoped wildcard: net.isIPv6 accepts the %zone, new URL() rejects it, + // so the zone must be stripped before canonicalizing (else it throws). + "::%eth0", + // Legacy inet_aton spellings the OS still binds as 0.0.0.0. + "0", + "0x0", + "0x0.0.0.0", + "000.000.000.000", + "0.0", // short inet_aton form (1–3 parts) still binds the wildcard + "0.0.0", + "00000000000", // bare octal zero — parseAddressPart's [0-9]+ handles octal + "0x00000000", // bare hex zero + ])("flags the all-interfaces host %j", (host) => { + expect(isAllInterfacesHost(host)).toBe(true); + }); + + it.each([ + "localhost", + "127.0.0.1", + "::1", + "[::1]", + "example.com", + "192.168.1.50", + "1.0.0.0", + "0.0.0.1", + "::ffff:0", // canonicalizes to ::ffff:0, a distinct address — not the wildcard + "0.0.0.0.0", // 5 octets — not a valid IPv4, must not be flagged (parts.length > 4) + "fe80::1%eth0", // a zone-scoped link-local — a real bind host, not the wildcard + "::1%lo0", // zone-scoped loopback — must not be flagged and must not throw + ])("does not flag the loopback/specific host %j", (host) => { + expect(isAllInterfacesHost(host)).toBe(false); + }); +}); + describe("formatHostForUrl", () => { it.each([ ["::1", "[::1]"], diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 8ca14976b..2a752e172 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -1,61 +1,9 @@ import { describe, it, expect } from "vitest"; import { BIND_ALL_INTERFACES_ENV, - isAllInterfacesHost, resolveBindHostname, } from "../../../../server/resolve-bind-host.js"; -describe("isAllInterfacesHost", () => { - it.each([ - "0.0.0.0", - "::", - "", - " 0.0.0.0 ", - " :: ", - "[::]", - "0:0:0:0:0:0:0:0", - "::ffff:0.0.0.0", - "::ffff:0:0", - // IPv6 wildcard spellings that canonicalize to `::`. - "::0", - "0::0", - "::0.0.0.0", - "0:0::0", - "0000:0000:0000:0000:0000:0000:0000:0000", - // Zone-scoped wildcard: net.isIPv6 accepts the %zone, new URL() rejects it, - // so the zone must be stripped before canonicalizing (else it throws). - "::%eth0", - // Legacy inet_aton spellings the OS still binds as 0.0.0.0. - "0", - "0x0", - "0x0.0.0.0", - "000.000.000.000", - "0.0", // short inet_aton form (1–3 parts) still binds the wildcard - "0.0.0", - "00000000000", // bare octal zero — parseAddressPart's [0-9]+ handles octal - "0x00000000", // bare hex zero - ])("flags the all-interfaces host %j", (host) => { - expect(isAllInterfacesHost(host)).toBe(true); - }); - - it.each([ - "localhost", - "127.0.0.1", - "::1", - "[::1]", - "example.com", - "192.168.1.50", - "1.0.0.0", - "0.0.0.1", - "::ffff:0", // canonicalizes to ::ffff:0, a distinct address — not the wildcard - "0.0.0.0.0", // 5 octets — not a valid IPv4, must not be flagged (parts.length > 4) - "fe80::1%eth0", // a zone-scoped link-local — a real bind host, not the wildcard - "::1%lo0", // zone-scoped loopback — must not be flagged and must not throw - ])("does not flag the loopback/specific host %j", (host) => { - expect(isAllInterfacesHost(host)).toBe(false); - }); -}); - describe("resolveBindHostname", () => { it("defaults to localhost when HOST is unset", () => { expect(resolveBindHostname({})).toBe("localhost"); diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index 4d6c9d33b..e1a972b23 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -69,3 +69,71 @@ export function canonicalUrlHost(host: string): string { return formatted; } } + +/** + * Canonical spellings of the all-interfaces (unspecified) address. Every IPv6 + * wildcard spelling (`::`, `::0`, `0:0:…:0`, `::0.0.0.0`, …) canonicalizes to + * `::` and the IPv4-mapped wildcard to `::ffff:0:0` (see {@link canonicalizeIpv6}), + * so those two entries cover the whole IPv6 family; `0.0.0.0` is the IPv4 + * wildcard and `""` is Node's `listen()` unspecified address. All bind *every* + * interface. {@link isAllInterfacesHost} additionally folds the legacy IPv4 + * spellings the OS resolver still binds as `0.0.0.0`. + */ +const ALL_INTERFACES_LITERALS = new Set(["", "0.0.0.0", "::", "::ffff:0:0"]); + +/** + * Canonicalize an IPv6 literal via the WHATWG URL serializer, which compresses + * zero-runs — so `::0`, `0::0`, `::0.0.0.0`, and the fully-expanded + * `0000:…:0000` all collapse to `::`, and `::ffff:0.0.0.0` → `::ffff:0:0`. This + * is what lets a small literal set catch every all-zero IPv6 spelling rather + * than only the handful written out. Non-IPv6 input passes through unchanged. + * + * The zone index (`%eth0`) is stripped first: `net.isIPv6` accepts it but + * `new URL()` rejects a zone id outright (even `%25`-encoded), so passing it + * through would throw. The zone is irrelevant to *which* address this is, so + * dropping it is correct for detection — `::%eth0` still canonicalizes to `::`. + */ +function canonicalizeIpv6(value: string): string { + if (!isIPv6(value)) return value; + const [address] = value.split("%"); + return new URL(`http://[${address}]`).hostname.slice(1, -1); +} + +/** + * Parse one dotted-address part (or a bare address) the way the C `inet_aton` + * resolver does — decimal, `0`-prefixed octal, and `0x`-prefixed hex are all + * accepted. Returns `NaN` for anything non-numeric (e.g. a hostname label). + */ +function parseAddressPart(part: string): number { + if (/^0x[0-9a-f]+$/.test(part)) return parseInt(part, 16); + if (/^[0-9]+$/.test(part)) return parseInt(part, 10); + return NaN; +} + +/** + * True when `value` is an all-zero IPv4 address in any legacy spelling the OS + * still binds as the `0.0.0.0` wildcard: the bare integer `0`, `0x0`, dotted + * `0.0.0.0`, `000.000.000.000`, `0x0.0.0.0`, and the short forms `0.0` / `0.0.0` + * (Node/`inet_aton` accept 1–4 parts; `parseAddressPart`'s `[0-9]+` branch does + * double duty for decimal and `0`-prefixed octal). Guards the near-miss + * bypasses of the literal set above. The `> 4` reject is intentional — 1–3 + * parts are valid `inet_aton` spellings, so this is deliberately NOT + * `parts.length === 4`. Called from {@link isAllInterfacesHost} with any + * normalized host, so it may receive an IPv6 literal (`::1`) — the dot-split + * yields a single non-numeric part → `NaN` → `false`, which is correct. + */ +function isAllZeroIpv4(value: string): boolean { + const parts = value.split("."); + if (parts.length > 4) return false; + return parts.every((part) => parseAddressPart(part) === 0); +} + +/** + * True when `host` binds all interfaces (`0.0.0.0` / `::` / empty / their legacy + * spellings) rather than loopback only. Shared by the web bind guard, the + * banner/sandbox wildcard→`localhost` substitution, and the CLI deep-link. + */ +export function isAllInterfacesHost(host: string): boolean { + const normalized = canonicalizeIpv6(stripBrackets(host.trim().toLowerCase())); + return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); +} From a45e0bdd19a63ec9189be4b1f467f509c184c135 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:47:15 -0400 Subject: [PATCH 21/31] =?UTF-8?q?review:=20round-19=20fix=20=E2=80=94=20cl?= =?UTF-8?q?ose=20the=20IDNA/fullwidth=20wildcard=20bypass=20in=20the=20bin?= =?UTF-8?q?d=20guard=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address AD1 of the round-19 review of #1796 — a genuine bypass: - AD1 (medium-low): isAllInterfacesHost tested the RAW host, but Node's resolver applies the IDNA Unicode→ASCII mapping before parsing the literal, so HOST="0" (U+FF10), "00.0.0", "0.0.0.0" all bind 0.0.0.0 while the guard said nothing. Verified with dns.lookup + a real http.listen() on macOS (the reviewer verified Linux). Fixed by canonicalizing inside isAllInterfacesHost (run canonicalUrlHost first, which applies the same new URL() IDNA mapping the allow-list side already benefits from) — a verified strict superset: every prior positive stays flagged, every negative (incl. fullwidth 127.0.0.1 → loopback) stays unflagged, the fullwidth wildcards flip to flagged. This also closes AD2 (the CLI/guard predicate-on-raw vs value-canonical asymmetry) since the predicate now normalizes internally — the guard reasons about the address, not the spelling. Tests: fullwidth positives/negative in hostUrl.test.ts + a resolveBindHostname refusal case for HOST="0". - AD3: doc entries updated — hostUrl.ts now owns the all-interfaces DETECTION (isAllInterfacesHost), resolve-bind-host.ts owns the POLICY (opt-in + error). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 15 +++++++------ README.md | 2 +- clients/cli/tsconfig.tsbuildinfo | 1 + .../web/src/test/core/node/hostUrl.test.ts | 6 +++++ .../server/resolve-bind-host.test.ts | 22 ++++++++++++------- core/node/hostUrl.ts | 9 +++++++- 6 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 clients/cli/tsconfig.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index e13de48c1..b26948740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,10 @@ inspector/ │ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server), │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions), -│ │ │ # resolve-bind-host.ts (shared bind-host guard: refuses an +│ │ │ # resolve-bind-host.ts (bind-host POLICY: refuses an │ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES; -│ │ │ # used by both bind points — web-server-config.ts + vite.config.ts — #1795), +│ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost. +│ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795), │ │ │ # browser-externalized-builtin-gate.ts (build-gate logic that fails │ │ │ # `vite build` on a browser-externalized Node built-in — #1769) │ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) @@ -55,11 +56,11 @@ inspector/ │ │ └── state/ # InspectorClient state stores consumed by core/react/ │ ├── node/ # Node-only shared helpers: version.ts (readInspectorVersion, │ │ # walks to the root package.json), hostUrl.ts (shared host -│ │ # normalization — formatHostForUrl brackets IPv6, canonicalUrlHost -│ │ # canonicalizes a bind host the way a browser builds `Origin` so -│ │ # the web origin allow-list/banner/sandbox URL + the CLI deep-link -│ │ # agree; also stripBrackets. Used across clients/web/server, -│ │ # clients/cli, and core/auth/node — #1795) +│ │ # normalization + detection — formatHostForUrl brackets IPv6, +│ │ # canonicalUrlHost canonicalizes a bind host the way a browser +│ │ # builds `Origin`, isAllInterfacesHost is the wildcard-bind +│ │ # predicate the guard is built on; also stripBrackets. Used across +│ │ # clients/web/server, clients/cli, and core/auth/node — #1795) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests. diff --git a/README.md b/README.md index 96f4a074d..7c2ac8c40 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ inspector/ │ ├── json/ # JSON + parameter/argument conversion utilities │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import -│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalization: format + canonicalize) +│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces detection) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests diff --git a/clients/cli/tsconfig.tsbuildinfo b/clients/cli/tsconfig.tsbuildinfo new file mode 100644 index 000000000..fd6b5955b --- /dev/null +++ b/clients/cli/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/clear-stored-auth-for-relogin.ts","./src/cli-oauth-navigation.ts","./src/cli.ts","./src/clioauth.ts","./src/error-handler.ts","./src/index.ts","./src/open-url.ts","./src/style.ts","./src/handlers/collect-app-info.ts","./src/handlers/connect-timeout.ts","./src/handlers/consume-outcome.ts","./src/handlers/emit-result.ts","./src/handlers/format-output.ts","./src/handlers/method-types.ts","./src/handlers/run-method.ts","./src/handlers/servers-list.ts","./src/utils/awaitable-log.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index e41816c1f..bc7a4c3d1 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -35,6 +35,11 @@ describe("isAllInterfacesHost", () => { "0.0.0", "00000000000", // bare octal zero — parseAddressPart's [0-9]+ handles octal "0x00000000", // bare hex zero + // Fullwidth (IDNA-mapped) spellings the resolver folds to 0.0.0.0 before + // binding — the guard must canonicalize, not read the raw string. + "0", // fullwidth "0" + "00.0.0", // fullwidth "0" + ".0.0" + "0.0.0.0", // fullwidth dots ])("flags the all-interfaces host %j", (host) => { expect(isAllInterfacesHost(host)).toBe(true); }); @@ -52,6 +57,7 @@ describe("isAllInterfacesHost", () => { "0.0.0.0.0", // 5 octets — not a valid IPv4, must not be flagged (parts.length > 4) "fe80::1%eth0", // a zone-scoped link-local — a real bind host, not the wildcard "::1%lo0", // zone-scoped loopback — must not be flagged and must not throw + "127.0.0.1", // fullwidth 127 → 127.0.0.1, a real loopback address, not the wildcard ])("does not flag the loopback/specific host %j", (host) => { expect(isAllInterfacesHost(host)).toBe(false); }); diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 2a752e172..77c6326a4 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -27,14 +27,20 @@ describe("resolveBindHostname", () => { expect(resolveBindHostname({ HOST: "fe80::1%eth0" })).toBe("fe80::1%eth0"); }); - it.each(["0.0.0.0", "::", "", "0", "0x0.0.0.0", "::ffff:0.0.0.0", " 0 "])( - "refuses the all-interfaces host %j without the opt-in", - (host) => { - expect(() => resolveBindHostname({ HOST: host })).toThrow( - new RegExp(BIND_ALL_INTERFACES_ENV), - ); - }, - ); + it.each([ + "0.0.0.0", + "::", + "", + "0", + "0x0.0.0.0", + "::ffff:0.0.0.0", + " 0 ", + "0", // fullwidth "0" — the resolver binds it as 0.0.0.0, so the guard must refuse it + ])("refuses the all-interfaces host %j without the opt-in", (host) => { + expect(() => resolveBindHostname({ HOST: host })).toThrow( + new RegExp(BIND_ALL_INTERFACES_ENV), + ); + }); it.each(["true", "TRUE", "1", " true "])( "allows 0.0.0.0 when the opt-in is %j", diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index e1a972b23..219c76b00 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -132,8 +132,15 @@ function isAllZeroIpv4(value: string): boolean { * True when `host` binds all interfaces (`0.0.0.0` / `::` / empty / their legacy * spellings) rather than loopback only. Shared by the web bind guard, the * banner/sandbox wildcard→`localhost` substitution, and the CLI deep-link. + * + * The value is run through {@link canonicalUrlHost} first — Node's resolver + * applies the IDNA Unicode→ASCII mapping before parsing the literal, so a + * fullwidth `HOST="0"` (U+FF10) binds `0.0.0.0`; canonicalizing (via `new URL`, + * which applies the same mapping) before the address check catches those + * spellings and keeps this predicate reasoning about the *address* the socket + * binds, not the raw string. Idempotent for ASCII hosts. */ export function isAllInterfacesHost(host: string): boolean { - const normalized = canonicalizeIpv6(stripBrackets(host.trim().toLowerCase())); + const normalized = canonicalizeIpv6(stripBrackets(canonicalUrlHost(host))); return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); } From 28fa0010be9815343d0bbb2a64d425dab3bd3e4c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:58:24 -0400 Subject: [PATCH 22/31] =?UTF-8?q?review:=20round-20=20fixes=20=E2=80=94=20?= =?UTF-8?q?untrack=20stray=20tsbuildinfo,=20self-explaining=20guard=20mess?= =?UTF-8?q?age=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-20 review of #1796: - AE1 (should-block): the round-19 commit accidentally tracked clients/cli/tsconfig.tsbuildinfo — a tsc incremental cache written by a stray `tsc -b` I ran in the cli dir during verification. git rm --cached it, deleted it, and added *.tsbuildinfo to the root .gitignore (web already routes its own into node_modules/.tmp via tsBuildInfoFile; the root had no catch-all). Verified `cli npm run typecheck` leaves the tree clean and no tsbuildinfo is tracked. - AE2: the guard's refusal message now names the resolved address when it differs from the typed spelling — HOST="0" (fullwidth, renders like 0) → "…HOST=\"0\" (resolves to 0.0.0.0)…", so the "bind a loopback host" guidance isn't a non-sequitur for the IDNA/0x0/::0 spellings the guard now catches. Test added. - AE3a: softened the "one normalized value drives the branch" comments at the three call sites — isAllInterfacesHost canonicalizes internally now, so computing `h` once is belt-and-braces, not what makes them agree. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .gitignore | 4 ++++ clients/cli/tsconfig.tsbuildinfo | 1 - clients/web/server/resolve-bind-host.ts | 12 +++++++++++- clients/web/server/sandbox-controller.ts | 3 ++- clients/web/server/web-server-config.ts | 10 ++++++---- .../integration/server/resolve-bind-host.test.ts | 7 +++++++ 6 files changed, 30 insertions(+), 7 deletions(-) delete mode 100644 clients/cli/tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index 93d61ce45..7e0989944 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ clients/web/dist clients/**/build clients/**/coverage test-servers/build +# TypeScript incremental build cache (written by `tsc -b`); web routes its own +# into node_modules/.tmp via tsBuildInfoFile, but a stray `tsc -b` elsewhere +# drops one next to the tsconfig — never source. +*.tsbuildinfo .env /.playwright-mcp/ diff --git a/clients/cli/tsconfig.tsbuildinfo b/clients/cli/tsconfig.tsbuildinfo deleted file mode 100644 index fd6b5955b..000000000 --- a/clients/cli/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/clear-stored-auth-for-relogin.ts","./src/cli-oauth-navigation.ts","./src/cli.ts","./src/clioauth.ts","./src/error-handler.ts","./src/index.ts","./src/open-url.ts","./src/style.ts","./src/handlers/collect-app-info.ts","./src/handlers/connect-timeout.ts","./src/handlers/consume-outcome.ts","./src/handlers/emit-result.ts","./src/handlers/format-output.ts","./src/handlers/method-types.ts","./src/handlers/run-method.ts","./src/handlers/servers-list.ts","./src/utils/awaitable-log.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 35081e0a3..9c39e3c3c 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -8,6 +8,7 @@ */ import { + canonicalUrlHost, isAllInterfacesHost, stripBrackets, } from "../../../core/node/hostUrl.ts"; @@ -41,8 +42,17 @@ export function resolveBindHostname( ): 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); + const shown = + resolved === host.toLowerCase() + ? `HOST="${host}"` + : `HOST="${host}" (resolves to ${resolved})`; throw new Error( - `Refusing to bind HOST="${host}": this exposes the MCP Inspector to your ` + + `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 ` + diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 82e9794f7..81fafa897 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -204,7 +204,8 @@ export function createSandboxController( // 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). - // One normalized value drives both the predicate and the value. + // (`isAllInterfacesHost` canonicalizes internally too, so passing the + // pre-canonicalized host is belt-and-braces.) const canonicalHost = canonicalUrlHost(host); const urlHost = isAllInterfacesHost(canonicalHost) ? "localhost" diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 5909d295a..8671d9789 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -175,7 +175,9 @@ export function printServerBanner( // (`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). One normalized value (`h`) drives both the predicate and the value. + // :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); @@ -274,9 +276,9 @@ export function defaultAllowedOrigins( hostname: string, port: number, ): string[] { - // One normalized value drives every branch (so they can't disagree about the - // same host): the loopback lookup, the wildcard check, and the emitted origin - // all read `h`. + // 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); diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 77c6326a4..2b3915d4a 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -42,6 +42,13 @@ describe("resolveBindHostname", () => { ); }); + it("names the resolved wildcard address when the spelling differs (fullwidth)", () => { + // `HOST="0"` renders like `0`, so the message shows it resolves to 0.0.0.0. + expect(() => resolveBindHostname({ HOST: "0" })).toThrow( + /resolves to 0\.0\.0\.0/, + ); + }); + it.each(["true", "TRUE", "1", " true "])( "allows 0.0.0.0 when the opt-in is %j", (flag) => { From b0d55e3f90a88b3a3824afbe050af61642fe6656 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:09:06 -0400 Subject: [PATCH 23/31] =?UTF-8?q?review:=20round-21=20=E2=80=94=20guard=20?= =?UTF-8?q?the=20CLI=20OAuth=20callback=20bind=20(AF1),=20message=20+=20do?= =?UTF-8?q?c=20nits=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-21 review of #1796: - AF1: the runner OAuth callback listener (--callback-url / MCP_OAUTH_CALLBACK_URL, CLI+TUI) bound whatever host you gave it, so --callback-url http://0.0.0.0:6276/oauth/callback put the listener that receives the OAuth *authorization code* on every interface — a credential-interception risk, and the same wildcard bind the web path refuses. Since isAllInterfacesHost moved to core/node/hostUrl.ts (round 18) and runner-oauth-callback.ts already imports from it, this is a one-import guard: parseRunnerOAuthCallbackUrl now throws an actionable error for an all-interfaces callback host (must be loopback). Handled in-PR rather than deferred — same class as the PR's core. Tests: 0.0.0.0 / [::] rejected, 127.0.0.1 accepted. - AF2a: the guard message compares de-bracketed, so a plain HOST="::" isn't reported as "(resolves to [::])". - AF2b: reworded the ALLOWED_ORIGINS comment — a browser's Origin is always its page's http(s) origin (even for a WebSocket handshake), so a ws:// entry could never match; only http(s) entries are useful. - AF2c (cli/tui tsBuildInfoFile): left as-is — they run tsc --noEmit (no buildinfo); the *.tsbuildinfo gitignore backstop covers a stray tsc -b. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/server/resolve-bind-host.ts | 4 +++- clients/web/server/web-server-config.ts | 6 ++++-- .../core/auth/runner-oauth-callback.test.ts | 19 +++++++++++++++++++ core/auth/node/runner-oauth-callback.ts | 13 ++++++++++++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 9c39e3c3c..778cbf117 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -47,8 +47,10 @@ export function resolveBindHostname( // `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 so a plain `HOST="::"` isn't reported as "(resolves + // to [::])" — the bracketing is a URL-authority detail, not a resolution. const shown = - resolved === host.toLowerCase() + stripBrackets(resolved) === host.toLowerCase() ? `HOST="${host}"` : `HOST="${host}" (resolves to ${resolved})`; throw new Error( diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 8671d9789..6e77bd28e 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -366,8 +366,10 @@ export function buildWebServerConfig( // 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. Only http(s)/ws(s) origins - // are supported (the app is served over http(s)); entries need a scheme. + // 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 diff --git a/clients/web/src/test/core/auth/runner-oauth-callback.test.ts b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts index 4836cbe3a..1a3a406b8 100644 --- a/clients/web/src/test/core/auth/runner-oauth-callback.test.ts +++ b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts @@ -76,6 +76,25 @@ describe("runner OAuth callback URL", () => { ).toThrow(/must use http scheme/); }); + it.each([ + "http://0.0.0.0:6276/oauth/callback", + "http://[::]:6276/oauth/callback", + ])( + "rejects an all-interfaces callback host %j (the code listener must be loopback)", + (url) => { + expect(() => parseRunnerOAuthCallbackUrl(url)).toThrow( + /must bind a loopback host/, + ); + }, + ); + + it("still accepts a loopback callback host", () => { + expect( + parseRunnerOAuthCallbackUrl("http://127.0.0.1:6276/oauth/callback") + .hostname, + ).toBe("127.0.0.1"); + }); + it("formatRunnerOAuthRedirectUrl round-trips default config", () => { const config = parseRunnerOAuthCallbackUrl(); expect(formatRunnerOAuthRedirectUrl(config)).toBe( diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts index d52489606..ea14a3f78 100644 --- a/core/auth/node/runner-oauth-callback.ts +++ b/core/auth/node/runner-oauth-callback.ts @@ -10,7 +10,7 @@ * unsupported; override via `--callback-url` / `MCP_OAUTH_CALLBACK_URL`. */ -import { formatHostForUrl } from "../../node/hostUrl.js"; +import { formatHostForUrl, isAllInterfacesHost } from "../../node/hostUrl.js"; export const RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME = "127.0.0.1"; /** Default loopback port for TUI/CLI OAuth callback (6276 ≈ T9 "MCPO", MCP OAuth). */ @@ -60,6 +60,17 @@ export function parseRunnerOAuthCallbackUrl( throw new Error("OAuth callback URL must include a hostname"); } /* v8 ignore stop */ + // The callback listener receives the OAuth *authorization code*, so it must be + // loopback — binding all interfaces would expose the credential to the local + // network. Same guard the web bind points enforce (isAllInterfacesHost). + if (isAllInterfacesHost(hostname)) { + throw new Error( + `OAuth callback URL must bind a loopback host, not the all-interfaces ` + + `address "${hostname}": the callback listener receives the OAuth ` + + `authorization code, so exposing it to the network risks credential ` + + `interception. Use 127.0.0.1.`, + ); + } /* v8 ignore next -- an http: URL always has a non-empty pathname (min "/"), so the fallback is unreachable */ const pathname = url.pathname || "/"; let port: number; From 79392f53bd822296fcba783d5281291bcf5562be Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:21:28 -0400 Subject: [PATCH 24/31] =?UTF-8?q?review:=20round-22=20=E2=80=94=20OAuth=20?= =?UTF-8?q?callback=20loopback-only=20+=20bracket=20fixes=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-22 review of #1796 (follow-ons in the AF1 OAuth surface): - AG1: the round-21 OAuth guard only rejected the wildcard, but its message and the PR description say "loopback" — and a LAN/routable callback host (192.168.1.50, example.com) was still accepted, delivering the OAuth authorization code over plaintext http across the network. http: is only spec-legal (RFC 8252 §7.3) for loopback, so this now enforces loopback-only via a new isLoopbackHost predicate in core/node/hostUrl.ts (localhost / 127.0.0.0/8 incl. IPv4-mapped / [::1], all canonicalized). No opt-in hatch — the workaround is loopback + a port-forward. Tests: LAN/hostname rejected, all loopback spellings accepted. - AG3: parseRunnerOAuthCallbackUrl returned url.hostname verbatim (bracketed for IPv6), so a legit --callback-url http://[::1]:6276/… passed the guard then died ENOTFOUND at listen(). Now de-bracketed (formatRunnerOAuthRedirectUrl re-brackets for the redirect URI). Test asserts [::1] → hostname "::1". - AG2: the resolveBindHostname message compare now strips brackets on BOTH sides, so a bracketed HOST="[::]" doesn't get a no-op "(resolves to [::])" hint. Test. - AG4: documented the loopback restriction in the CLI + TUI READMEs (--callback-url row). PR description's "refuses a non-loopback callback host" is now accurate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/README.md | 2 +- clients/tui/README.md | 2 +- clients/web/server/resolve-bind-host.ts | 7 +++-- .../core/auth/runner-oauth-callback.test.ts | 20 ++++++++----- .../web/src/test/core/node/hostUrl.test.ts | 30 +++++++++++++++++++ .../server/resolve-bind-host.test.ts | 12 ++++++++ core/auth/node/runner-oauth-callback.ts | 29 +++++++++++------- core/node/hostUrl.ts | 13 ++++++++ 8 files changed, 92 insertions(+), 23 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 6abb958fe..e1b3c4ab5 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -183,7 +183,7 @@ Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs tha | `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | | `--client-secret ` | — | OAuth client secret; overrides `client.json`. | | `--client-metadata-url ` | — | CIMD metadata URL; overrides `client.json`. | -| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). | +| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). Must bind a **loopback** host (`localhost` / `127.0.0.0/8` / `[::1]`) — the listener receives the authorization code over plaintext `http`, so a non-loopback host hard-errors (no opt-in; use a port-forward if the browser is elsewhere). | | — | `MCP_AUTO_OPEN_ENABLED` | Browser auto-open **and** non-TTY interactive-OAuth admit: `true` (allow interactive OAuth without a TTY **and** force-open the browser — same as the web launcher), `false` (never open), unset (open on a TTY unless `VITEST` is set). For CI that must not hang, prefer `--stored-auth-only`. | **Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line: diff --git a/clients/tui/README.md b/clients/tui/README.md index f80c7a686..ea5582156 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -58,7 +58,7 @@ OAuth redirect URIs must match **exactly** what you register on the authorizatio | `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | | `--client-secret ` | — | OAuth client secret (confidential clients); overrides `client.json`. | | `--client-metadata-url ` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. | -| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). | +| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). Must bind a **loopback** host (`localhost` / `127.0.0.0/8` / `[::1]`); a non-loopback host hard-errors, since the listener receives the authorization code over plaintext `http`. | #### Authenticating in the TUI diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index 778cbf117..a6d6fda1e 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -47,10 +47,11 @@ export function resolveBindHostname( // `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 so a plain `HOST="::"` isn't reported as "(resolves - // to [::])" — the bracketing is a URL-authority detail, not a resolution. + // 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) === host.toLowerCase() + stripBrackets(resolved) === stripBrackets(host.toLowerCase()) ? `HOST="${host}"` : `HOST="${host}" (resolves to ${resolved})`; throw new Error( diff --git a/clients/web/src/test/core/auth/runner-oauth-callback.test.ts b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts index 1a3a406b8..76f5d8a8d 100644 --- a/clients/web/src/test/core/auth/runner-oauth-callback.test.ts +++ b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts @@ -77,10 +77,12 @@ describe("runner OAuth callback URL", () => { }); it.each([ - "http://0.0.0.0:6276/oauth/callback", - "http://[::]:6276/oauth/callback", + "http://0.0.0.0:6276/oauth/callback", // wildcard + "http://[::]:6276/oauth/callback", // IPv6 wildcard + "http://192.168.1.50:6276/oauth/callback", // LAN — plaintext code over the network + "http://example.com:6276/oauth/callback", // routable hostname ])( - "rejects an all-interfaces callback host %j (the code listener must be loopback)", + "rejects a non-loopback callback host %j (the code listener must be loopback)", (url) => { expect(() => parseRunnerOAuthCallbackUrl(url)).toThrow( /must bind a loopback host/, @@ -88,11 +90,13 @@ describe("runner OAuth callback URL", () => { }, ); - it("still accepts a loopback callback host", () => { - expect( - parseRunnerOAuthCallbackUrl("http://127.0.0.1:6276/oauth/callback") - .hostname, - ).toBe("127.0.0.1"); + it.each([ + ["http://127.0.0.1:6276/oauth/callback", "127.0.0.1"], + ["http://localhost:6276/oauth/callback", "localhost"], + ["http://127.5:6276/oauth/callback", "127.0.0.5"], // 127.0.0.0/8, shorthand normalized by new URL + ["http://[::1]:6276/oauth/callback", "::1"], // de-bracketed so listen() can bind it + ])("accepts the loopback callback host %j", (url, expectedHostname) => { + expect(parseRunnerOAuthCallbackUrl(url).hostname).toBe(expectedHostname); }); it("formatRunnerOAuthRedirectUrl round-trips default config", () => { diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index bc7a4c3d1..27a6b3320 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -3,9 +3,39 @@ import { canonicalUrlHost, formatHostForUrl, isAllInterfacesHost, + isLoopbackHost, stripBrackets, } from "@inspector/core/node/hostUrl.js"; +describe("isLoopbackHost", () => { + it.each([ + "localhost", + "LOCALHOST", + "127.0.0.1", + "127.5", // 127.0.0.0/8 shorthand + "0x7f.0.0.1", + "2130706433", + "::1", + "[::1]", + "0:0:0:0:0:0:0:1", + "::ffff:127.0.0.1", // IPv4-mapped loopback → unmapped to 127.0.0.1 + ])("flags the loopback host %j", (host) => { + expect(isLoopbackHost(host)).toBe(true); + }); + + it.each([ + "0.0.0.0", + "::", + "192.168.1.50", + "example.com", + "126.0.0.1", // adjacent to but outside 127/8 + "128.0.0.1", + "0.0.0.127", // bare "127" resolves here, not loopback + ])("does not flag the non-loopback host %j", (host) => { + expect(isLoopbackHost(host)).toBe(false); + }); +}); + describe("isAllInterfacesHost", () => { it.each([ "0.0.0.0", diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 2b3915d4a..19fa9ea78 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -49,6 +49,18 @@ describe("resolveBindHostname", () => { ); }); + it("does not add a resolved-address hint for an already-canonical bracketed HOST", () => { + // `HOST="[::]"` is accepted-then-refused as the wildcard; the message must + // not read "(resolves to [::])" — same value, just bracketed. + try { + resolveBindHostname({ HOST: "[::]" }); + throw new Error("expected resolveBindHostname to throw"); + } catch (err) { + expect((err as Error).message).not.toContain("resolves to"); + expect((err as Error).message).toContain(BIND_ALL_INTERFACES_ENV); + } + }); + it.each(["true", "TRUE", "1", " true "])( "allows 0.0.0.0 when the opt-in is %j", (flag) => { diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts index ea14a3f78..61affb700 100644 --- a/core/auth/node/runner-oauth-callback.ts +++ b/core/auth/node/runner-oauth-callback.ts @@ -10,7 +10,11 @@ * unsupported; override via `--callback-url` / `MCP_OAUTH_CALLBACK_URL`. */ -import { formatHostForUrl, isAllInterfacesHost } from "../../node/hostUrl.js"; +import { + formatHostForUrl, + isLoopbackHost, + stripBrackets, +} from "../../node/hostUrl.js"; export const RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME = "127.0.0.1"; /** Default loopback port for TUI/CLI OAuth callback (6276 ≈ T9 "MCPO", MCP OAuth). */ @@ -60,15 +64,17 @@ export function parseRunnerOAuthCallbackUrl( throw new Error("OAuth callback URL must include a hostname"); } /* v8 ignore stop */ - // The callback listener receives the OAuth *authorization code*, so it must be - // loopback — binding all interfaces would expose the credential to the local - // network. Same guard the web bind points enforce (isAllInterfacesHost). - if (isAllInterfacesHost(hostname)) { + // The callback listener receives the OAuth *authorization code* over plaintext + // http, so it must be loopback (RFC 8252 §7.3) — a routable host would deliver + // the credential in cleartext over the network. Reject anything non-loopback, + // not just the all-interfaces wildcard. + if (!isLoopbackHost(hostname)) { throw new Error( - `OAuth callback URL must bind a loopback host, not the all-interfaces ` + - `address "${hostname}": the callback listener receives the OAuth ` + - `authorization code, so exposing it to the network risks credential ` + - `interception. Use 127.0.0.1.`, + `OAuth callback URL must bind a loopback host (localhost / 127.0.0.1 / ` + + `[::1]), not "${hostname}": the callback listener receives the OAuth ` + + `authorization code over plaintext http, so a network-reachable host ` + + `risks credential interception. Use 127.0.0.1 (and a port-forward if the ` + + `browser is elsewhere).`, ); } /* v8 ignore next -- an http: URL always has a non-empty pathname (min "/"), so the fallback is unreachable */ @@ -89,7 +95,10 @@ export function parseRunnerOAuthCallbackUrl( } /* v8 ignore stop */ } - return { hostname, port, pathname }; + // De-bracket an IPv6 host (`url.hostname` keeps the brackets) so `listen()` can + // consume it — `[::1]` fails ENOTFOUND, `::1` binds. formatRunnerOAuthRedirectUrl + // re-brackets via formatHostForUrl for the redirect URI. + return { hostname: stripBrackets(hostname), port, pathname }; } /** diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index 219c76b00..f9b136e07 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -144,3 +144,16 @@ export function isAllInterfacesHost(host: string): boolean { const normalized = canonicalizeIpv6(stripBrackets(canonicalUrlHost(host))); return ALL_INTERFACES_LITERALS.has(normalized) || isAllZeroIpv4(normalized); } + +/** + * True when `host` is a loopback address — `localhost`, anything in `127.0.0.0/8` + * (incl. IPv4-mapped `::ffff:127.x`, which {@link canonicalUrlHost} unmaps), or + * `::1`. Canonicalized first, so non-canonical spellings (`127.1`, `0x7f.1`, + * `2130706433`, `0:0:…:1`) resolve correctly. Used to constrain the OAuth + * callback listener, which must be loopback (it receives the authorization code + * over plaintext `http`; RFC 8252 §7.3 only sanctions that for loopback). + */ +export function isLoopbackHost(host: string): boolean { + const h = canonicalUrlHost(host); + return h === "localhost" || h === "[::1]" || /^127(\.\d{1,3}){3}$/.test(h); +} From bec591900daa774de938d5e9b9108f0a1c954734 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:31:17 -0400 Subject: [PATCH 25/31] =?UTF-8?q?review:=20round-23=20nits=20=E2=80=94=20b?= =?UTF-8?q?ound=20isLoopbackHost=20octets,=20accept=20trailing-dot,=20help?= =?UTF-8?q?=20text=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-23 review of #1796 (converged; all nits): - AH1: isLoopbackHost's octet regex was `\d{1,3}`, and canonicalUrlHost returns the raw input when new URL() throws — so 127.999.0.1 / 127.0.0.256 read as loopback on the fallback path. Unreachable via the OAuth guard (new URL throws first) but its sibling isAllInterfacesHost is called with raw env values, so bounded the octets (25[0-5]|2[0-4]\d|1?\d?\d). Negative tests added. - AH2: a root-anchored FQDN (localhost.) binds loopback but WHATWG keeps the dot, so it was rejected; strip a trailing dot before the localhost compare. Test. - AH3b: --help text for --callback-url (cli + tui) now says "must be loopback". - AH3c: the AG2 test captures the message outside try/catch so a stopped-throwing regression reports the real cause. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/src/cli.ts | 2 +- clients/tui/tui.tsx | 2 +- .../web/src/test/core/node/hostUrl.test.ts | 7 +++++++ .../server/resolve-bind-host.test.ts | 20 +++++++++++-------- core/node/hostUrl.ts | 12 +++++++++-- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 00d3861f5..061b5740e 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -676,7 +676,7 @@ async function parseArgs(argv?: string[]): Promise { ) .option( "--callback-url ", - `OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`, + `OAuth redirect/callback listener URL; must be loopback (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`, ) .option( "--use-stored-auth", diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index aac8dd25c..3169a7901 100644 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -59,7 +59,7 @@ export async function runTui(args?: string[]): Promise { ) .option( "--callback-url ", - `OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`, + `OAuth redirect/callback listener URL; must be loopback (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`, ) .argument( "[target...]", diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index 27a6b3320..4c6ef2414 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -19,6 +19,9 @@ describe("isLoopbackHost", () => { "[::1]", "0:0:0:0:0:0:0:1", "::ffff:127.0.0.1", // IPv4-mapped loopback → unmapped to 127.0.0.1 + "127.255.255.255", // top of 127.0.0.0/8 + "localhost.", // root-anchored FQDN (WHATWG keeps the dot; binds loopback) + "127.0.0.1.", // trailing dot on an IP literal (WHATWG strips it) ])("flags the loopback host %j", (host) => { expect(isLoopbackHost(host)).toBe(true); }); @@ -31,6 +34,10 @@ describe("isLoopbackHost", () => { "126.0.0.1", // adjacent to but outside 127/8 "128.0.0.1", "0.0.0.127", // bare "127" resolves here, not loopback + // Out-of-range octets survive canonicalUrlHost's non-URL fallback — the + // bounded regex must still reject them. + "127.999.0.1", + "127.0.0.256", ])("does not flag the non-loopback host %j", (host) => { expect(isLoopbackHost(host)).toBe(false); }); diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index 19fa9ea78..c6de0adab 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -51,14 +51,18 @@ describe("resolveBindHostname", () => { it("does not add a resolved-address hint for an already-canonical bracketed HOST", () => { // `HOST="[::]"` is accepted-then-refused as the wildcard; the message must - // not read "(resolves to [::])" — same value, just bracketed. - try { - resolveBindHostname({ HOST: "[::]" }); - throw new Error("expected resolveBindHostname to throw"); - } catch (err) { - expect((err as Error).message).not.toContain("resolves to"); - expect((err as Error).message).toContain(BIND_ALL_INTERFACES_ENV); - } + // not read "(resolves to [::])" — same value, just bracketed. Capture the + // message outside try/catch so a stopped-throwing regression reports clearly. + let message = ""; + expect(() => { + try { + resolveBindHostname({ HOST: "[::]" }); + } catch (err) { + message = (err as Error).message; + throw err; + } + }).toThrow(BIND_ALL_INTERFACES_ENV); + expect(message).not.toContain("resolves to"); }); it.each(["true", "TRUE", "1", " true "])( diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index f9b136e07..5e53a6be3 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -154,6 +154,14 @@ export function isAllInterfacesHost(host: string): boolean { * over plaintext `http`; RFC 8252 §7.3 only sanctions that for loopback). */ export function isLoopbackHost(host: string): boolean { - const h = canonicalUrlHost(host); - return h === "localhost" || h === "[::1]" || /^127(\.\d{1,3}){3}$/.test(h); + // Drop a root FQDN dot (`localhost.` binds loopback but WHATWG keeps the dot); + // IP literals never carry one (WHATWG strips it for those). + const h = canonicalUrlHost(host).replace(/\.$/, ""); + // Octets are range-bounded (`canonicalUrlHost` returns the raw input when + // `new URL` throws, so `\d{1,3}` alone would pass `127.999.0.1`). + return ( + h === "localhost" || + h === "[::1]" || + /^127(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(h) + ); } From ea7625f7c9400a217b4a089ef115b816978cfdd0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:44:49 -0400 Subject: [PATCH 26/31] =?UTF-8?q?review:=20round-24=20=E2=80=94=20OAuth=20?= =?UTF-8?q?guard=20error=20is=20a=20usage=20error,=20not=20auth=5Frequired?= =?UTF-8?q?=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-24 review of #1796 (all on the newest guard's error-reporting): - AJ1: the OAuth callback guard's message contains "OAuth", so the CLI exit-code heuristic classified it as AUTH_REQUIRED (exit 3) — telling an automated caller to re-auth on a config error. And parseRunnerOAuthCallbackUrl runs for EVERY --cli invocation, so an exported MCP_OAUTH_CALLBACK_URL=http://0.0.0.0/… made every call exit 3. Wrapped the call in cli.ts and rethrow as CliExitCodeError(USAGE) → exit 1, code "error". Test asserts exit 1 / not auth_required. - AJ2: the TUI bin (clients/tui/index.ts) and the launcher bin (clients/launcher/src/index.ts) printed a config-error Error as a raw stack trace; now print err.message like run-web.ts's house pattern, so the guard's actionable message isn't buried. - AJ3: AGENTS.md + root README name isLoopbackHost in the core/node/hostUrl entry. - AJ4a: CSP_HOST_SOURCE now excludes `*` too, matching its "can't corrupt/widen" invariant. AJ4b: pin isLoopbackHost's canonical ::ffff:7f00:1 serialization. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 3 ++- README.md | 2 +- clients/cli/__tests__/stored-auth.test.ts | 21 +++++++++++++++++++ clients/cli/src/cli.ts | 15 ++++++++++++- clients/launcher/src/index.ts | 7 ++++++- clients/tui/index.ts | 5 ++++- clients/web/server/sandbox-controller.ts | 8 ++++--- .../web/src/test/core/node/hostUrl.test.ts | 1 + .../server/sandbox-controller.test.ts | 1 + 9 files changed, 55 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b26948740..9b9ac1b77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,8 @@ inspector/ │ │ # normalization + detection — formatHostForUrl brackets IPv6, │ │ # canonicalUrlHost canonicalizes a bind host the way a browser │ │ # builds `Origin`, isAllInterfacesHost is the wildcard-bind -│ │ # predicate the guard is built on; also stripBrackets. Used across +│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth +│ │ # callback listener; also stripBrackets. Used across │ │ # clients/web/server, clients/cli, and core/auth/node — #1795) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends diff --git a/README.md b/README.md index 7c2ac8c40..8296b0293 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ inspector/ │ ├── json/ # JSON + parameter/argument conversion utilities │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import -│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces detection) +│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 0f389e181..c59e9c71f 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -618,6 +618,27 @@ describe("--print-handoff", () => { expect(out.deepLink.startsWith("http://localhost:16274/?")).toBe(true); }); + it("classifies a bad --callback-url as a usage error, not auth_required", async () => { + // The guard's message contains "OAuth"; without the explicit exit-code pin + // the heuristic would map it to auth_required (exit 3) and tell an automated + // caller to re-auth on a config error. Fires before connect, so any server. + const result = await runCli([ + "--server-url", + "https://x.example/mcp", + "--method", + "tools/list", + "--callback-url", + "http://0.0.0.0:6276/oauth/callback", + ]); + expect(result.exitCode).toBe(1); + const envelope = JSON.parse(result.stderr.trim()) as { + error: { code: string; message: string }; + }; + expect(envelope.error.code).toBe("error"); + expect(envelope.error.code).not.toBe("auth_required"); + expect(envelope.error.message).toContain("must bind a loopback host"); + }); + it("derives transport=sse for an SSE server (auto-detected from the /sse path)", async () => { const result = await runCli( ["--print-handoff", "--server-url", "https://x.example/sse"], diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 061b5740e..a672be42f 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -1039,7 +1039,20 @@ export async function runCli(argv?: string[]): Promise { relogin, } = parsed; const clientConfig = await loadRunnerClientConfig({ clientConfigPath }); - const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl); + // A bad --callback-url / MCP_OAUTH_CALLBACK_URL is a *usage* error, but its + // messages contain "OAuth", which the exit-code heuristic (error-handler.ts) + // would otherwise classify as AUTH_REQUIRED (exit 3) — telling an automated + // caller to kick the auth flow instead of fixing the flag. `core/` can't + // import CliExitCodeError, so pin the class here. + let callbackUrlConfig: RunnerOAuthCallbackConfig; + try { + callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl); + } catch (err) { + throw new CliExitCodeError( + EXIT_CODES.USAGE, + err instanceof Error ? err.message : String(err), + ); + } await callMethod( serverConfig, serverSettings, diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts index a8e4f2dc9..00600f6e7 100644 --- a/clients/launcher/src/index.ts +++ b/clients/launcher/src/index.ts @@ -58,6 +58,11 @@ async function run(): Promise { } run().catch((err: unknown) => { - console.error("Error running MCP Inspector:", err); + // Print the message, not the Error stack — a startup config error (a bad flag, + // the OAuth callback loopback guard) should read as actionable, not internal. + console.error( + "Error running MCP Inspector:", + err instanceof Error ? err.message : err, + ); process.exit(1); }); diff --git a/clients/tui/index.ts b/clients/tui/index.ts index 46f6d8fed..36139879d 100644 --- a/clients/tui/index.ts +++ b/clients/tui/index.ts @@ -13,7 +13,10 @@ const isMain = if (isMain) { runTui(process.argv).catch((err: unknown) => { - console.error(err); + // Print the message, not the stack — a startup config error (e.g. the + // OAuth callback loopback guard) should read as an actionable message, not + // an internal fault. Matches run-web.ts's house pattern. + console.error("Error:", err instanceof Error ? err.message : err); process.exit(1); }); } diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 81fafa897..16eaac729 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -48,10 +48,12 @@ const LOOPBACK_FRAME_ANCESTORS = ["http://127.0.0.1:*", "http://localhost:*"]; * 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 the header. Only values matching this - * shape survive into {@link sandboxFrameAncestors}. + * 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. Only + * values matching this shape survive into {@link sandboxFrameAncestors}. */ -const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]]+$/i; +const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]*]+$/i; /** * The proxy's `frame-ancestors` sources. The embedder is the inspector app diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index 4c6ef2414..9aa191f53 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -19,6 +19,7 @@ describe("isLoopbackHost", () => { "[::1]", "0:0:0:0:0:0:0:1", "::ffff:127.0.0.1", // IPv4-mapped loopback → unmapped to 127.0.0.1 + "::ffff:7f00:1", // its canonical serialization (what new URL().hostname yields) "127.255.255.255", // top of 127.0.0.0/8 "localhost.", // root-anchored FQDN (WHATWG keeps the dot; binds loopback) "127.0.0.1.", // trailing dot on an IP literal (WHATWG strips it) diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 99bebaec1..5db8d1b3f 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -39,6 +39,7 @@ describe("sandboxFrameAncestors", () => { "http://a:1; sandbox", "http://b:2\nX-Evil: 1", "http://[::1]:6274", + "http://*.example.com", // a wildcard would widen the embedder set "not a url", ]), ).toBe("frame-ancestors http://good.example:6274"); From 4ff578c9b13142175de2600daf15146c6f413c52 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:57:54 -0400 Subject: [PATCH 27/31] =?UTF-8?q?review:=20round-25=20=E2=80=94=20preserve?= =?UTF-8?q?=20CLI=20exit=20codes/envelope=20through=20the=20launcher=20(#1?= =?UTF-8?q?795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-25 review of #1796: - AK1 (medium-low, pre-existing but AJ1 walked into it): `mcp-inspector --cli` goes through the launcher, which imports runCli as a module — so the CLI bin's own .catch(handleError) never fires (isMain is false), and the launcher's top-level catch flattened EVERY failure to exit 1 with a plain-text message, losing the whole EXIT_CODES map and the JSON {"error":…} envelope the CLI README documents. Exported handleError from the CLI entry and routed the launcher's --cli branch through it, so exit codes (incl. AJ1's usage/exit-1 and auth_required/exit-3) and the envelope survive the documented invocation. New smoke:cli assertion drives the built launcher with a bad --callback-url and checks the JSON envelope + exit 1 (verified green). - AK2: the TUI + launcher top-level sinks are catch-alls, not config boundaries — print err.message for readability but append err.stack under DEBUG / MCP_DEBUG so a real fault is still debuggable. - AK3: note LOOPBACK_FRAME_ANCESTORS is a trusted constant that deliberately uses :* and is emitted as-is (doesn't pass CSP_HOST_SOURCE, by design). - AK4a: doc isLoopbackHost / isAllInterfacesHost expect a bare host (no port). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/src/index.ts | 6 +++- clients/launcher/src/index.ts | 17 +++++++++-- clients/tui/index.ts | 6 +++- clients/web/server/sandbox-controller.ts | 6 ++-- core/node/hostUrl.ts | 2 ++ scripts/smoke-cli.mjs | 38 +++++++++++++++++++++++- 6 files changed, 68 insertions(+), 7 deletions(-) diff --git a/clients/cli/src/index.ts b/clients/cli/src/index.ts index 93c1fa228..a03023939 100644 --- a/clients/cli/src/index.ts +++ b/clients/cli/src/index.ts @@ -5,7 +5,11 @@ import { fileURLToPath } from "url"; import { runCli, validLogLevels } from "./cli.js"; import { handleError } from "./error-handler.js"; -export { runCli, validLogLevels }; +// `handleError` is exported so the launcher (which imports `runCli` as a module +// and owns the rejection) can route a `mcp-inspector --cli` failure through the +// CLI's own sink — preserving the EXIT_CODES map and the JSON `{"error":…}` +// envelope that this bin's own `.catch` provides only when run directly. +export { runCli, validLogLevels, handleError }; const __filename = fileURLToPath(import.meta.url); const isMain = diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts index 00600f6e7..3f23e0329 100644 --- a/clients/launcher/src/index.ts +++ b/clients/launcher/src/index.ts @@ -49,8 +49,17 @@ async function run(): Promise { const { runWeb } = await import(clientEntry("web")); await runWeb(forwardedArgv); } else if (mode === "cli") { - const { runCli } = await import(clientEntry("cli")); - await runCli(forwardedArgv); + // Route a CLI failure through the CLI's own error sink so `mcp-inspector + // --cli` (a module import here, so the CLI bin's own `.catch(handleError)` + // never fires) still honors the EXIT_CODES map and emits the JSON + // `{"error":…}` envelope its README documents — instead of the generic + // exit-1 catch-all below. + const { runCli, handleError } = await import(clientEntry("cli")); + try { + await runCli(forwardedArgv); + } catch (err) { + handleError(err); // writes the envelope + process.exit(code); never returns + } } else { const { runTui } = await import(clientEntry("tui")); await runTui(forwardedArgv); @@ -60,9 +69,13 @@ async function run(): Promise { run().catch((err: unknown) => { // Print the message, not the Error stack — a startup config error (a bad flag, // the OAuth callback loopback guard) should read as actionable, not internal. + // The stack is still available under DEBUG / MCP_DEBUG for real faults. console.error( "Error running MCP Inspector:", err instanceof Error ? err.message : err, ); + if ((process.env.DEBUG || process.env.MCP_DEBUG) && err instanceof Error) { + console.error(err.stack); + } process.exit(1); }); diff --git a/clients/tui/index.ts b/clients/tui/index.ts index 36139879d..cc04143c2 100644 --- a/clients/tui/index.ts +++ b/clients/tui/index.ts @@ -15,8 +15,12 @@ if (isMain) { runTui(process.argv).catch((err: unknown) => { // Print the message, not the stack — a startup config error (e.g. the // OAuth callback loopback guard) should read as an actionable message, not - // an internal fault. Matches run-web.ts's house pattern. + // an internal fault. Matches run-web.ts's house pattern. The stack is still + // available under DEBUG / MCP_DEBUG for a real fault. console.error("Error:", err instanceof Error ? err.message : err); + if ((process.env.DEBUG || process.env.MCP_DEBUG) && err instanceof Error) { + console.error(err.stack); + } process.exit(1); }); } diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 16eaac729..e4b0d53e1 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -50,8 +50,10 @@ const LOOPBACK_FRAME_ANCESTORS = ["http://127.0.0.1:*", "http://localhost:*"]; * 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. Only - * values matching this shape survive into {@link sandboxFrameAncestors}. + * 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; diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index 5e53a6be3..0d41a48b5 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -132,6 +132,7 @@ function isAllZeroIpv4(value: string): boolean { * True when `host` binds all interfaces (`0.0.0.0` / `::` / empty / their legacy * spellings) rather than loopback only. Shared by the web bind guard, the * banner/sandbox wildcard→`localhost` substitution, and the CLI deep-link. + * Expects a bare host (no port) — callers pass `url.hostname` or an env `HOST`. * * The value is run through {@link canonicalUrlHost} first — Node's resolver * applies the IDNA Unicode→ASCII mapping before parsing the literal, so a @@ -152,6 +153,7 @@ export function isAllInterfacesHost(host: string): boolean { * `2130706433`, `0:0:…:1`) resolve correctly. Used to constrain the OAuth * callback listener, which must be loopback (it receives the authorization code * over plaintext `http`; RFC 8252 §7.3 only sanctions that for loopback). + * Expects a bare host (no port) — the caller passes `url.hostname`. */ export function isLoopbackHost(host: string): boolean { // Drop a root FQDN dot (`localhost.` binds loopback but WHATWG keeps the dot); diff --git a/scripts/smoke-cli.mjs b/scripts/smoke-cli.mjs index 6649740d3..83dbd62a4 100644 --- a/scripts/smoke-cli.mjs +++ b/scripts/smoke-cli.mjs @@ -404,11 +404,47 @@ try { await httpServer.stop(); } + // 9) A usage error through the launcher --cli path still emits the JSON + // envelope and the right exit code (#1795 AK1): the launcher imports runCli + // as a module, so the CLI's own error sink must be routed through, or the + // documented EXIT_CODES/envelope contract is lost. A bad --callback-url is + // a usage error whose message contains "OAuth" (would otherwise misclassify + // as auth_required/exit 3). + const badCallback = runCli([ + "--catalog", + catalogPath, + "--server", + "test", + "--method", + "tools/list", + "--callback-url", + "http://0.0.0.0:6276/oauth/callback", + ]); + if (badCallback.status !== 1) { + fail( + `bad --callback-url should exit 1 (usage) through the launcher, got ${badCallback.status}\n${badCallback.stderr}`, + ); + } + let envelope; + try { + envelope = JSON.parse(badCallback.stderr.trim()); + } catch { + fail( + `bad --callback-url should emit a JSON {"error":…} envelope through the launcher; got:\n${badCallback.stderr}`, + ); + } + if (envelope?.error?.code !== "error") { + fail( + `bad --callback-url envelope should be code "error", got ${JSON.stringify(envelope)}`, + ); + } + console.log( "smoke:cli OK — tools/list over stdio via --catalog; default-catalog seed; " + "read-only --config error (no seed); --catalog/--config conflict; " + "unknown --server error; multi-server --server selection; --header merge; " + - "HTTP config-header lift + --header override on the wire", + "HTTP config-header lift + --header override on the wire; " + + "launcher --cli usage error → JSON envelope + exit 1", ); } finally { rmSync(work, { recursive: true, force: true }); From c4392c736ff278c250c98f1d9539329864122b74 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:08:34 -0400 Subject: [PATCH 28/31] =?UTF-8?q?review:=20round-26=20nits=20=E2=80=94=20s?= =?UTF-8?q?tale-build=20guard,=20non-1=20exit=20smoke,=20DEBUG=3D0,=20docs?= =?UTF-8?q?=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-26 review of #1796 (converged; all nits): - AL1: guard the launcher --cli catch against a stale cli/build where handleError is undefined — rethrow to the generic sink instead of masking the real error with "handleError is not a function". - AL3: smoke:cli step 10 exercises a NON-1 exit code through the launcher (--use-stored-auth with no token → exit 3 / envelope code no_stored_token, offline), pinning that the whole EXIT_CODES map — not just exit 1 — survives the launcher. Verified green. - AL2: comment on the launcher --cli success path (let the loop drain rather than process.exit(0), which risks truncating a large piped stdout on macOS). - AL4a: DEBUG=0 / "false" / empty now read as off for the stack-on-debug gate (launcher + tui), via a small parse — DEBUG stays useful for the debug package. - AL4b: launcher README documents that --cli failures are machine-readable JSON (exit-code map + envelope) while --web/--tui are prose. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/launcher/README.md | 2 ++ clients/launcher/src/index.ts | 23 +++++++++++++++++++++- clients/tui/index.ts | 11 ++++++++++- scripts/smoke-cli.mjs | 36 ++++++++++++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/clients/launcher/README.md b/clients/launcher/README.md index 73e268c61..c03f52aab 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -10,6 +10,8 @@ The launcher is the package that provides the global `mcp-inspector` binary (e.g All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret config or env vars. +**Error reporting.** A `--cli` failure is routed through the CLI's own error sink, so `mcp-inspector --cli` preserves the CLI exit-code map (`1` usage, `2` no-app, `3` auth-required, `4` unreachable, `5` tool-error) and its machine-readable `{"error":{…}}` stderr envelope — the same as invoking the CLI bin directly. `--web` / `--tui` failures print a human-readable `Error: ` and exit `1` (append `MCP_DEBUG=1` for the stack). + ## Web server-list flags (`--web`) `mcp-inspector --web` chooses which server list the UI shows and whether it is diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts index 3f23e0329..390d82b24 100644 --- a/clients/launcher/src/index.ts +++ b/clients/launcher/src/index.ts @@ -13,6 +13,20 @@ function clientEntry(client: "web" | "cli" | "tui"): string { ).href; } +/** + * Whether to append the error stack on a top-level failure. Gated on `MCP_DEBUG` + * or `DEBUG` being *meaningfully* set — `"0"` / `"false"` / empty read as off, so + * a stray `DEBUG=0` doesn't turn stacks on (and `DEBUG` stays useful for the + * `debug` package's namespace filter, which is any non-empty value). + */ +function wantsDebugStack(): boolean { + const on = (v: string | undefined): boolean => { + const s = v?.trim().toLowerCase(); + return !!s && s !== "0" && s !== "false"; + }; + return on(process.env.MCP_DEBUG) || on(process.env.DEBUG); +} + const program = new Command(); program @@ -56,8 +70,15 @@ async function run(): Promise { // exit-1 catch-all below. const { runCli, handleError } = await import(clientEntry("cli")); try { + // On success, let `run()` resolve and the event loop drain (rather than a + // `process.exit(0)`) — the CLI closes its own transports, and an eager exit + // risks truncating a large stdout payload on a pipe (async on macOS). await runCli(forwardedArgv); } catch (err) { + // A stale `cli/build` (built before handleError was exported) would make + // this `undefined`; fall through to the generic sink with the real message + // rather than throwing "handleError is not a function" over it. + if (typeof handleError !== "function") throw err; handleError(err); // writes the envelope + process.exit(code); never returns } } else { @@ -74,7 +95,7 @@ run().catch((err: unknown) => { "Error running MCP Inspector:", err instanceof Error ? err.message : err, ); - if ((process.env.DEBUG || process.env.MCP_DEBUG) && err instanceof Error) { + if (wantsDebugStack() && err instanceof Error) { console.error(err.stack); } process.exit(1); diff --git a/clients/tui/index.ts b/clients/tui/index.ts index cc04143c2..61bd5e2c6 100644 --- a/clients/tui/index.ts +++ b/clients/tui/index.ts @@ -18,7 +18,16 @@ if (isMain) { // an internal fault. Matches run-web.ts's house pattern. The stack is still // available under DEBUG / MCP_DEBUG for a real fault. console.error("Error:", err instanceof Error ? err.message : err); - if ((process.env.DEBUG || process.env.MCP_DEBUG) && err instanceof Error) { + // Append the stack only when DEBUG / MCP_DEBUG is *meaningfully* set — "0" / + // "false" / empty read as off, so a stray DEBUG=0 doesn't force a stack. + const debugOn = (v: string | undefined): boolean => { + const s = v?.trim().toLowerCase(); + return !!s && s !== "0" && s !== "false"; + }; + if ( + (debugOn(process.env.MCP_DEBUG) || debugOn(process.env.DEBUG)) && + err instanceof Error + ) { console.error(err.stack); } process.exit(1); diff --git a/scripts/smoke-cli.mjs b/scripts/smoke-cli.mjs index 83dbd62a4..9ceb248a5 100644 --- a/scripts/smoke-cli.mjs +++ b/scripts/smoke-cli.mjs @@ -439,12 +439,46 @@ try { ); } + // 10) A NON-1 exit code survives the launcher too (the actual AK1 claim — the + // whole EXIT_CODES map, not just the exit-1 the old catch-all produced). + // --use-stored-auth with no stored token throws AUTH_REQUIRED (3) with + // envelope code "no_stored_token" *before* any connect, so it's offline. + const noStoredAuth = runCli( + [ + "--server-url", + "http://example.invalid/mcp", + "--method", + "tools/list", + "--use-stored-auth", + ], + { HOME: fakeHome, USERPROFILE: fakeHome }, + ); + if (noStoredAuth.status !== 3) { + fail( + `--use-stored-auth with no token should exit 3 (auth_required) through the launcher, got ${noStoredAuth.status}\n${noStoredAuth.stderr}`, + ); + } + let authEnvelope; + try { + authEnvelope = JSON.parse(noStoredAuth.stderr.trim()); + } catch { + fail( + `--use-stored-auth should emit a JSON envelope through the launcher; got:\n${noStoredAuth.stderr}`, + ); + } + if (authEnvelope?.error?.code !== "no_stored_token") { + fail( + `--use-stored-auth envelope should be code "no_stored_token", got ${JSON.stringify(authEnvelope)}`, + ); + } + console.log( "smoke:cli OK — tools/list over stdio via --catalog; default-catalog seed; " + "read-only --config error (no seed); --catalog/--config conflict; " + "unknown --server error; multi-server --server selection; --header merge; " + "HTTP config-header lift + --header override on the wire; " + - "launcher --cli usage error → JSON envelope + exit 1", + "launcher --cli usage error → JSON envelope + exit 1; " + + "launcher --cli auth error → JSON envelope + exit 3", ); } finally { rmSync(work, { recursive: true, force: true }); From b52e3763d49b55014b84d1478e994dd537eef65a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:17:21 -0400 Subject: [PATCH 29/31] =?UTF-8?q?review:=20round-27=20nits=20=E2=80=94=20s?= =?UTF-8?q?moke=20env-pin=20+=20docblock,=20comments=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-27 review of #1796 (converged; all nits): - AM4: pin MCP_OAUTH_CALLBACK_URL="" in smoke-cli's runCli/runCliAsync base env, so an ambient non-loopback value can't fail steps 1–8 (parseRunnerOAuthCallbackUrl runs on every --cli invocation). Mirrors prod-web-server.mjs's HOST pin. Verified smoke:cli still passes with a hostile MCP_OAUTH_CALLBACK_URL=http://0.0.0.0 exported. - AM1: smoke-cli docblock now lists steps 9 + 10 (the only guard on the AK1/AL3 launcher exit-code/envelope contract, since the bins are coverage-excluded). - AM3: note on the handleError call that its small envelope fits the pipe buffer, so AL2's large-stdout truncation caveat doesn't apply to it. - AM2: note on the tui debugOn copy that it mirrors the launcher's wantsDebugStack (the two bins can't import each other). - AM5: replaced the redundant `.not.toBe("auth_required")` assertion with a comment. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/__tests__/stored-auth.test.ts | 2 +- clients/launcher/src/index.ts | 3 +++ clients/tui/index.ts | 2 ++ scripts/smoke-cli.mjs | 19 +++++++++++++++++-- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index c59e9c71f..4a45ec33d 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -634,8 +634,8 @@ describe("--print-handoff", () => { const envelope = JSON.parse(result.stderr.trim()) as { error: { code: string; message: string }; }; + // "error" (USAGE), not "auth_required" — the point of pinning the exit code. expect(envelope.error.code).toBe("error"); - expect(envelope.error.code).not.toBe("auth_required"); expect(envelope.error.message).toContain("must bind a loopback host"); }); diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts index 390d82b24..faf5e39f9 100644 --- a/clients/launcher/src/index.ts +++ b/clients/launcher/src/index.ts @@ -79,6 +79,9 @@ async function run(): Promise { // this `undefined`; fall through to the generic sink with the real message // rather than throwing "handleError is not a function" over it. if (typeof handleError !== "function") throw err; + // write-then-exit like the direct bin; the envelope is a few hundred bytes, + // well inside the pipe buffer, so the truncation risk noted above (which is + // about a large stdout payload) doesn't apply to this stderr line. handleError(err); // writes the envelope + process.exit(code); never returns } } else { diff --git a/clients/tui/index.ts b/clients/tui/index.ts index 61bd5e2c6..33c91ece3 100644 --- a/clients/tui/index.ts +++ b/clients/tui/index.ts @@ -20,6 +20,8 @@ if (isMain) { console.error("Error:", err instanceof Error ? err.message : err); // Append the stack only when DEBUG / MCP_DEBUG is *meaningfully* set — "0" / // "false" / empty read as off, so a stray DEBUG=0 doesn't force a stack. + // (Duplicated from the launcher's `wantsDebugStack` — the two bins can't + // import each other; keep them in sync if this logic changes.) const debugOn = (v: string | undefined): boolean => { const s = v?.trim().toLowerCase(); return !!s && s !== "0" && s !== "false"; diff --git a/scripts/smoke-cli.mjs b/scripts/smoke-cli.mjs index 9ceb248a5..ef93bdfdf 100644 --- a/scripts/smoke-cli.mjs +++ b/scripts/smoke-cli.mjs @@ -23,6 +23,14 @@ * 8. Over an HTTP transport (in-process test server), a config-file `headers` * object is lifted onto the wire, and a CLI `--header` overrides it — the * headline lift→transport path of #1482, verified end to end. + * 9. A usage error through the launcher `--cli` path emits the JSON `{"error":…}` + * envelope and exits 1 (a bad `--callback-url`, whose message contains + * "OAuth", must NOT misclassify as auth_required — #1795 AK1/AJ1). + * 10. A non-1 exit code survives the launcher too: `--use-stored-auth` with no + * stored token exits 3 with envelope code `no_stored_token`, pinning that the + * whole EXIT_CODES map — not just exit 1 — reaches an automated caller + * through `mcp-inspector --cli`. (Steps 9–10 are the ONLY guard on that + * contract: `clients/{launcher,cli}/src/index.ts` are coverage-excluded.) * * Exits non-zero (failing CI / `npm run validate`) on any mismatch. * @@ -77,11 +85,18 @@ function ensureTestServer() { } } +// Neutralize env that would make an unrelated step fail: parseRunnerOAuthCallbackUrl +// runs on every --cli invocation, so an ambient MCP_OAUTH_CALLBACK_URL pointing at +// a non-loopback host would fail steps 1–8. Empty reads as unset in the parser +// (default 127.0.0.1:6276 applies); per-call extraEnv is spread after, so step 9 +// can still pass --callback-url explicitly. Mirrors prod-web-server.mjs's HOST pin. +const SMOKE_BASE_ENV = { MCP_OAUTH_CALLBACK_URL: "" }; + /** Run the launcher in --cli mode. Returns { status, stdout, stderr }. */ function runCli(args, extraEnv = {}) { const r = spawnSync(process.execPath, [launcher, "--cli", ...args], { cwd: repoRoot, - env: { ...process.env, ...extraEnv }, + env: { ...process.env, ...SMOKE_BASE_ENV, ...extraEnv }, encoding: "utf-8", }); return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; @@ -97,7 +112,7 @@ function runCliAsync(args, extraEnv = {}) { return new Promise((resolveRun) => { const child = spawn(process.execPath, [launcher, "--cli", ...args], { cwd: repoRoot, - env: { ...process.env, ...extraEnv }, + env: { ...process.env, ...SMOKE_BASE_ENV, ...extraEnv }, }); let stdout = ""; let stderr = ""; From 83a2dfe947d0251c9bf782c07f6db131fc851bad Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:29:55 -0400 Subject: [PATCH 30/31] =?UTF-8?q?review:=20round-28=20=E2=80=94=20correct?= =?UTF-8?q?=20dead-cover=20comments,=20ALLOWED=5FORIGINS-replaces=20doc=20?= =?UTF-8?q?(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-28 review of #1796 (converged; both non-blocking): - AP1 (comment-only): since AD1 (round 19) made isAllInterfacesHost run canonicalUrlHost FIRST, that call is the actual wildcard cover — canonicalizeIpv6, the ::ffff:0:0 literal, and isAllZeroIpv4 became a redundant second layer that (measured) changes no verdict for any bindable input. Corrected the comments so they no longer read as the cover, with an explicit "do NOT drop the canonicalUrlHost call" note so a future refactor doesn't regress. Kept the code as defense-in-depth (it does cover canonicalUrlHost's non-URL catch path). - AP2: ALLOWED_ORIGINS REPLACES the default list (exact-match, no merge), so the network-hosting recipe that set only a LAN origin silently dropped the loopback trio — breaking http://localhost (which the same paragraph/Docker banner promises) and the derived sandbox CSP. Documented "replaces, not merges — list every origin incl. loopback" and fixed the wildcard-bullet example to keep the loopback forms. - AP3a: isLoopbackHost negative test now pins "" (the wildcard's job, not loopback). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/README.md | 4 +- .../web/src/test/core/node/hostUrl.test.ts | 1 + core/node/hostUrl.ts | 47 ++++++++++++------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 3af9bf81e..c835aaf27 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -164,7 +164,7 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. -The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. +The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. ### Hosting on a network @@ -172,7 +172,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe - **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://:PORT` and a browser hitting that address is accepted with no extra config. (The host is canonicalized the way a browser is — so `HOST=127.1` advertises `http://127.0.0.1:PORT`, an IPv6 bind host is bracketed as `http://[2001:db8::1]:PORT`, and the port is dropped when it's the http default `:80` — matching what the browser sends.) - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. -- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it 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: `ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com`. +- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`. The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. diff --git a/clients/web/src/test/core/node/hostUrl.test.ts b/clients/web/src/test/core/node/hostUrl.test.ts index 9aa191f53..baf12d226 100644 --- a/clients/web/src/test/core/node/hostUrl.test.ts +++ b/clients/web/src/test/core/node/hostUrl.test.ts @@ -35,6 +35,7 @@ describe("isLoopbackHost", () => { "126.0.0.1", // adjacent to but outside 127/8 "128.0.0.1", "0.0.0.127", // bare "127" resolves here, not loopback + "", // empty is the wildcard (isAllInterfacesHost's job), not loopback // Out-of-range octets survive canonicalUrlHost's non-URL fallback — the // bounded regex must still reject them. "127.999.0.1", diff --git a/core/node/hostUrl.ts b/core/node/hostUrl.ts index 0d41a48b5..93a9a55c5 100644 --- a/core/node/hostUrl.ts +++ b/core/node/hostUrl.ts @@ -70,23 +70,31 @@ export function canonicalUrlHost(host: string): string { } } +// NB on layering: since round-19 (#1795 AD1), {@link isAllInterfacesHost} runs +// `canonicalUrlHost` FIRST, and that (via `new URL()`) already compresses IPv6 +// zero-runs, unmaps IPv4-mapped addresses, and folds every legacy `inet_aton` +// and IDNA spelling to `0.0.0.0`. So `canonicalUrlHost` is the actual wildcard +// cover; `ALL_INTERFACES_LITERALS`'s `::ffff:0:0` entry, `canonicalizeIpv6`, and +// `isAllZeroIpv4` below are a **redundant second layer** — measured to change no +// verdict for any bindable input. They're kept as defense-in-depth (and they do +// cover `canonicalUrlHost`'s non-URL `catch` fallback), but do NOT remove the +// `canonicalUrlHost` call from `isAllInterfacesHost` on the assumption these +// still catch the legacy spellings — they don't anymore. + /** - * Canonical spellings of the all-interfaces (unspecified) address. Every IPv6 - * wildcard spelling (`::`, `::0`, `0:0:…:0`, `::0.0.0.0`, …) canonicalizes to - * `::` and the IPv4-mapped wildcard to `::ffff:0:0` (see {@link canonicalizeIpv6}), - * so those two entries cover the whole IPv6 family; `0.0.0.0` is the IPv4 - * wildcard and `""` is Node's `listen()` unspecified address. All bind *every* - * interface. {@link isAllInterfacesHost} additionally folds the legacy IPv4 - * spellings the OS resolver still binds as `0.0.0.0`. + * Canonical spellings of the all-interfaces (unspecified) address: `0.0.0.0` + * (IPv4 wildcard), `::` (IPv6 wildcard), `::ffff:0:0` (IPv4-mapped wildcard — + * redundant now that `canonicalUrlHost` unmaps it to `0.0.0.0`), and `""` + * (Node's `listen()` unspecified address). See the layering note above. */ const ALL_INTERFACES_LITERALS = new Set(["", "0.0.0.0", "::", "::ffff:0:0"]); /** * Canonicalize an IPv6 literal via the WHATWG URL serializer, which compresses - * zero-runs — so `::0`, `0::0`, `::0.0.0.0`, and the fully-expanded - * `0000:…:0000` all collapse to `::`, and `::ffff:0.0.0.0` → `::ffff:0:0`. This - * is what lets a small literal set catch every all-zero IPv6 spelling rather - * than only the handful written out. Non-IPv6 input passes through unchanged. + * zero-runs — `::0` / `0::0` / `::0.0.0.0` / `0000:…:0000` → `::`, `::ffff:0.0.0.0` + * → `::ffff:0:0`. Redundant second layer (see the note above): `canonicalUrlHost` + * already ran the value through `new URL()`, so for any host it can produce this + * is the identity; kept for defense-in-depth. Non-IPv6 input passes through. * * The zone index (`%eth0`) is stripped first: `net.isIPv6` accepts it but * `new URL()` rejects a zone id outright (even `%25`-encoded), so passing it @@ -115,12 +123,17 @@ function parseAddressPart(part: string): number { * still binds as the `0.0.0.0` wildcard: the bare integer `0`, `0x0`, dotted * `0.0.0.0`, `000.000.000.000`, `0x0.0.0.0`, and the short forms `0.0` / `0.0.0` * (Node/`inet_aton` accept 1–4 parts; `parseAddressPart`'s `[0-9]+` branch does - * double duty for decimal and `0`-prefixed octal). Guards the near-miss - * bypasses of the literal set above. The `> 4` reject is intentional — 1–3 - * parts are valid `inet_aton` spellings, so this is deliberately NOT - * `parts.length === 4`. Called from {@link isAllInterfacesHost} with any - * normalized host, so it may receive an IPv6 literal (`::1`) — the dot-split - * yields a single non-numeric part → `NaN` → `false`, which is correct. + * double duty for decimal and `0`-prefixed octal). The `> 4` reject is + * intentional — 1–3 parts are valid `inet_aton` spellings, so this is + * deliberately NOT `parts.length === 4`. Called from {@link isAllInterfacesHost} + * with any normalized host, so it may receive an IPv6 literal (`::1`) — the + * dot-split yields a single non-numeric part → `NaN` → `false`, which is correct. + * + * Redundant second layer (see the note above `ALL_INTERFACES_LITERALS`): every + * bindable legacy spelling (`0`, `0x0`, `0.0`, `000.000.000.000`, `0`, …) is + * already folded to `0.0.0.0` by `canonicalUrlHost`, so this changes no verdict + * for a real host; its only live input is `canonicalUrlHost`'s non-URL `catch` + * fallback (e.g. bracketed non-IPv6 `[0.0.0]`, which fails `ENOTFOUND` anyway). */ function isAllZeroIpv4(value: string): boolean { const parts = value.split("."); From 13a825c1a88b5eb9662da782e03c1d7834bcd709 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:38:14 -0400 Subject: [PATCH 31/31] =?UTF-8?q?review:=20round-29=20=E2=80=94=20TUI=20sm?= =?UTF-8?q?oke=20env-pin=20+=20root=20README=20ALLOWED=5FORIGINS=20recipe?= =?UTF-8?q?=20(#1795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-29 review of #1796 (converged; two one-liners): - AQ1: smoke-tui.mjs pins MCP_OAUTH_CALLBACK_URL="" like smoke-cli's SMOKE_BASE_ENV (round 27 AM4) — parseRunnerOAuthCallbackUrl runs before the TUI's Ink render, so an ambient non-loopback value would crash the smoke before render. - AQ2: the root README Docker port-remap recipe now lists both loopback forms (http://localhost:8080,http://127.0.0.1:8080) and notes ALLOWED_ORIGINS replaces the default — the third recipe AP2's clause hadn't reached. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- README.md | 2 +- scripts/smoke-tui.mjs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8296b0293..652a9b41a 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ docker build -t mcp-inspector . docker run --rm -p 6274:6274 mcp-inspector ``` -The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). ## Contributing — `AGENTS.md` and `CLAUDE.md` diff --git a/scripts/smoke-tui.mjs b/scripts/smoke-tui.mjs index dfb5d08ad..0f241d243 100644 --- a/scripts/smoke-tui.mjs +++ b/scripts/smoke-tui.mjs @@ -90,7 +90,15 @@ const child = spawn( { cwd: repoRoot, // Redirect HOME so the TUI's storage never touches the real ~/.mcp-inspector. - env: { ...process.env, HOME: work, USERPROFILE: work }, + // Pin MCP_OAUTH_CALLBACK_URL="" (empty reads as unset) so an ambient + // non-loopback value can't crash the TUI before render via the loopback + // callback guard — same class smoke-cli.mjs's SMOKE_BASE_ENV neutralizes. + env: { + ...process.env, + MCP_OAUTH_CALLBACK_URL: "", + HOME: work, + USERPROFILE: work, + }, stdio: ["ignore", "pipe", "pipe"], }, );