feat(web): enforce localhost-only binding; expand loopback origin allow-list (#1796)

Closes #1795.

Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms.

Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Cliff Hall 2026-07-26 22:15:10 -04:00 • committed by GitHub
commit cea22d86b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -23,6 +23,10 @@ import {
parseHeaderPair,
} from "@inspector/core/mcp/node/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.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";
@ -422,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
@ -440,7 +451,7 @@ function buildHandoff(
if (apiToken) params.set("autoConnect", apiToken);
return {
serverUrl: normalizedUrl,
deepLink: `http://${host}:${clientPort}/?${params.toString()}`,
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
oauthStatePath: statePath,
apiToken: apiToken ?? null,
@ -665,7 +676,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
)
.option(
"--callback-url <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",
@ -1028,7 +1039,20 @@ export async function runCli(argv?: string[]): Promise<void> {
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,