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
2 changed files with 1610 additions and 86 deletions

View file

@ -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 = "";
@ -404,11 +419,81 @@ 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)}`,
);
}
// 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",
"HTTP config-header lift + --header override on the wire; " +
"launcher --cli usage error → JSON envelope + exit 1; " +
"launcher --cli auth error → JSON envelope + exit 3",
);
} finally {
rmSync(work, { recursive: true, force: true });

View file

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