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:
parent
33e88eac9f
commit
cea22d86b8
2 changed files with 1610 additions and 86 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue