inspector/clients/launcher/src/index.ts
Cliff Hall cea22d86b8
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)
2026-07-26 22:15:10 -04:00

105 lines
3.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Command } from "commander";
import { parseLauncherArgv } from "./parse-launcher-argv.js";
const launcherDir = dirname(fileURLToPath(import.meta.url));
function clientEntry(client: "web" | "cli" | "tui"): string {
return pathToFileURL(
join(launcherDir, "..", "..", client, "build", "index.js"),
).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
.name("mcp-inspector")
.description("MCP Inspector – run web UI, CLI, or TUI")
.option("--web", "Run web UI (default)")
.option("--cli", "Run CLI")
.option("--tui", "Run TUI");
let parsedArgv;
try {
parsedArgv = parseLauncherArgv(process.argv);
} catch (err) {
const message =
err instanceof Error ? err.message : "Invalid launcher arguments.";
console.error(`Error: ${message}`);
process.exit(1);
}
const { mode, forwardedArgv, hasPrefixModeFlag } = parsedArgv;
const helpOnly = process.argv.includes("-h") || process.argv.includes("--help");
if (helpOnly && !hasPrefixModeFlag) {
program.outputHelp();
console.log(
"\nMode flags (--web, --cli, --tui) must appear before app options. All following arguments are forwarded unchanged.",
);
process.exit(0);
}
async function run(): Promise<void> {
if (mode === "web") {
const { runWeb } = await import(clientEntry("web"));
await runWeb(forwardedArgv);
} else if (mode === "cli") {
// 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 {
// 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;
// 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 {
const { runTui } = await import(clientEntry("tui"));
await runTui(forwardedArgv);
}
}
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 (wantsDebugStack() && err instanceof Error) {
console.error(err.stack);
}
process.exit(1);
});