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

@ -580,6 +580,65 @@ 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("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("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 };
};
// "error" (USAGE), not "auth_required" — the point of pinning the exit code.
expect(envelope.error.code).toBe("error");
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"],