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
6 changed files with 1610 additions and 86 deletions
|
|
@ -76,6 +76,29 @@ describe("runner OAuth callback URL", () => {
|
|||
).toThrow(/must use http scheme/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://0.0.0.0:6276/oauth/callback", // wildcard
|
||||
"http://[::]:6276/oauth/callback", // IPv6 wildcard
|
||||
"http://192.168.1.50:6276/oauth/callback", // LAN — plaintext code over the network
|
||||
"http://example.com:6276/oauth/callback", // routable hostname
|
||||
])(
|
||||
"rejects a non-loopback callback host %j (the code listener must be loopback)",
|
||||
(url) => {
|
||||
expect(() => parseRunnerOAuthCallbackUrl(url)).toThrow(
|
||||
/must bind a loopback host/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:6276/oauth/callback", "127.0.0.1"],
|
||||
["http://localhost:6276/oauth/callback", "localhost"],
|
||||
["http://127.5:6276/oauth/callback", "127.0.0.5"], // 127.0.0.0/8, shorthand normalized by new URL
|
||||
["http://[::1]:6276/oauth/callback", "::1"], // de-bracketed so listen() can bind it
|
||||
])("accepts the loopback callback host %j", (url, expectedHostname) => {
|
||||
expect(parseRunnerOAuthCallbackUrl(url).hostname).toBe(expectedHostname);
|
||||
});
|
||||
|
||||
it("formatRunnerOAuthRedirectUrl round-trips default config", () => {
|
||||
const config = parseRunnerOAuthCallbackUrl();
|
||||
expect(formatRunnerOAuthRedirectUrl(config)).toBe(
|
||||
|
|
|
|||
164
clients/web/src/test/core/node/hostUrl.test.ts
Normal file
164
clients/web/src/test/core/node/hostUrl.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
formatHostForUrl,
|
||||
isAllInterfacesHost,
|
||||
isLoopbackHost,
|
||||
stripBrackets,
|
||||
} from "@inspector/core/node/hostUrl.js";
|
||||
|
||||
describe("isLoopbackHost", () => {
|
||||
it.each([
|
||||
"localhost",
|
||||
"LOCALHOST",
|
||||
"127.0.0.1",
|
||||
"127.5", // 127.0.0.0/8 shorthand
|
||||
"0x7f.0.0.1",
|
||||
"2130706433",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"0:0:0:0:0:0:0:1",
|
||||
"::ffff:127.0.0.1", // IPv4-mapped loopback → unmapped to 127.0.0.1
|
||||
"::ffff:7f00:1", // its canonical serialization (what new URL().hostname yields)
|
||||
"127.255.255.255", // top of 127.0.0.0/8
|
||||
"localhost.", // root-anchored FQDN (WHATWG keeps the dot; binds loopback)
|
||||
"127.0.0.1.", // trailing dot on an IP literal (WHATWG strips it)
|
||||
])("flags the loopback host %j", (host) => {
|
||||
expect(isLoopbackHost(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"192.168.1.50",
|
||||
"example.com",
|
||||
"126.0.0.1", // adjacent to but outside 127/8
|
||||
"128.0.0.1",
|
||||
"0.0.0.127", // bare "127" resolves here, not loopback
|
||||
"", // empty is the wildcard (isAllInterfacesHost's job), not loopback
|
||||
// Out-of-range octets survive canonicalUrlHost's non-URL fallback — the
|
||||
// bounded regex must still reject them.
|
||||
"127.999.0.1",
|
||||
"127.0.0.256",
|
||||
])("does not flag the non-loopback host %j", (host) => {
|
||||
expect(isLoopbackHost(host)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllInterfacesHost", () => {
|
||||
it.each([
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"",
|
||||
" 0.0.0.0 ",
|
||||
" :: ",
|
||||
"[::]",
|
||||
"0:0:0:0:0:0:0:0",
|
||||
"::ffff:0.0.0.0",
|
||||
"::ffff:0:0",
|
||||
// IPv6 wildcard spellings that canonicalize to `::`.
|
||||
"::0",
|
||||
"0::0",
|
||||
"::0.0.0.0",
|
||||
"0:0::0",
|
||||
"0000:0000:0000:0000:0000:0000:0000:0000",
|
||||
// Zone-scoped wildcard: net.isIPv6 accepts the %zone, new URL() rejects it,
|
||||
// so the zone must be stripped before canonicalizing (else it throws).
|
||||
"::%eth0",
|
||||
// Legacy inet_aton spellings the OS still binds as 0.0.0.0.
|
||||
"0",
|
||||
"0x0",
|
||||
"0x0.0.0.0",
|
||||
"000.000.000.000",
|
||||
"0.0", // short inet_aton form (1–3 parts) still binds the wildcard
|
||||
"0.0.0",
|
||||
"00000000000", // bare octal zero — parseAddressPart's [0-9]+ handles octal
|
||||
"0x00000000", // bare hex zero
|
||||
// Fullwidth (IDNA-mapped) spellings the resolver folds to 0.0.0.0 before
|
||||
// binding — the guard must canonicalize, not read the raw string.
|
||||
"0", // fullwidth "0"
|
||||
"00.0.0", // fullwidth "0" + ".0.0"
|
||||
"0.0.0.0", // fullwidth dots
|
||||
])("flags the all-interfaces host %j", (host) => {
|
||||
expect(isAllInterfacesHost(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"example.com",
|
||||
"192.168.1.50",
|
||||
"1.0.0.0",
|
||||
"0.0.0.1",
|
||||
"::ffff:0", // canonicalizes to ::ffff:0, a distinct address — not the wildcard
|
||||
"0.0.0.0.0", // 5 octets — not a valid IPv4, must not be flagged (parts.length > 4)
|
||||
"fe80::1%eth0", // a zone-scoped link-local — a real bind host, not the wildcard
|
||||
"::1%lo0", // zone-scoped loopback — must not be flagged and must not throw
|
||||
"127.0.0.1", // fullwidth 127 → 127.0.0.1, a real loopback address, not the wildcard
|
||||
])("does not flag the loopback/specific host %j", (host) => {
|
||||
expect(isAllInterfacesHost(host)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHostForUrl", () => {
|
||||
it.each([
|
||||
["::1", "[::1]"],
|
||||
["fe80::1", "[fe80::1]"],
|
||||
[" ::1 ", "[::1]"],
|
||||
["fe80::1%eth0", "[fe80::1]"], // zone id dropped — a URL host can't carry one
|
||||
["::1%lo0", "[::1]"],
|
||||
["[fe80::1%eth0]", "[fe80::1]"], // bracketed-with-zone → still a valid URL host
|
||||
])("brackets the IPv6 literal %j", (host, expected) => {
|
||||
expect(formatHostForUrl(host)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["localhost", "127.0.0.1", "192.168.1.50", "example.com", "[::1]"])(
|
||||
"passes the non-IPv6 / already-bracketed host %j through",
|
||||
(host) => {
|
||||
expect(formatHostForUrl(host)).toBe(host.trim());
|
||||
},
|
||||
);
|
||||
|
||||
it("does not bracket a non-IPv6 value that merely contains a colon", () => {
|
||||
// A mistyped host:port must not be wrapped as [host:port].
|
||||
expect(formatHostForUrl("localhost:6274")).toBe("localhost:6274");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripBrackets", () => {
|
||||
it.each([
|
||||
["[::1]", "::1"],
|
||||
["[fe80::1%eth0]", "fe80::1%eth0"],
|
||||
["[]", ""], // zero-or-more: an empty bracket pair reduces to ""
|
||||
["127.0.0.1", "127.0.0.1"],
|
||||
])("strips a surrounding bracket pair from %j", (host, expected) => {
|
||||
expect(stripBrackets(host)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalUrlHost", () => {
|
||||
it.each([
|
||||
["127.1", "127.0.0.1"],
|
||||
["0x7f.0.0.1", "127.0.0.1"],
|
||||
["2130706433", "127.0.0.1"],
|
||||
["0:0:0:0:0:0:0:1", "[::1]"],
|
||||
["::0001", "[::1]"],
|
||||
["LOCALHOST", "localhost"],
|
||||
["Example.COM", "example.com"],
|
||||
["fe80::1", "[fe80::1]"],
|
||||
// IPv4-mapped IPv6 → the dotted IPv4 the socket answers on (intentional
|
||||
// divergence from browser canonicalization).
|
||||
["::ffff:127.0.0.1", "127.0.0.1"],
|
||||
["::ffff:192.168.1.50", "192.168.1.50"],
|
||||
// A distinct address is unchanged.
|
||||
["127.0.0.2", "127.0.0.2"],
|
||||
])("canonicalizes %j to %j", (host, expected) => {
|
||||
expect(canonicalUrlHost(host)).toBe(expected);
|
||||
});
|
||||
|
||||
it("falls back to the formatted value when the host isn't a parseable URL", () => {
|
||||
expect(canonicalUrlHost("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
BIND_ALL_INTERFACES_ENV,
|
||||
resolveBindHostname,
|
||||
} from "../../../../server/resolve-bind-host.js";
|
||||
|
||||
describe("resolveBindHostname", () => {
|
||||
it("defaults to localhost when HOST is unset", () => {
|
||||
expect(resolveBindHostname({})).toBe("localhost");
|
||||
});
|
||||
|
||||
it("returns a loopback HOST unchanged", () => {
|
||||
expect(resolveBindHostname({ HOST: "127.0.0.1" })).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("trims the returned host so detection and the bind value agree", () => {
|
||||
expect(resolveBindHostname({ HOST: " 127.0.0.1 " })).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("returns a bracketed IPv6 HOST bare so listen() can bind it", () => {
|
||||
expect(resolveBindHostname({ HOST: "[::1]" })).toBe("::1");
|
||||
});
|
||||
|
||||
it("keeps the zone index on a link-local HOST for listen()", () => {
|
||||
// The guard must not throw on a zone-scoped host, and must return it with
|
||||
// the zone intact (listen() needs the zone to pick the interface).
|
||||
expect(resolveBindHostname({ HOST: "fe80::1%eth0" })).toBe("fe80::1%eth0");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"",
|
||||
"0",
|
||||
"0x0.0.0.0",
|
||||
"::ffff:0.0.0.0",
|
||||
" 0 ",
|
||||
"0", // fullwidth "0" — the resolver binds it as 0.0.0.0, so the guard must refuse it
|
||||
])("refuses the all-interfaces host %j without the opt-in", (host) => {
|
||||
expect(() => resolveBindHostname({ HOST: host })).toThrow(
|
||||
new RegExp(BIND_ALL_INTERFACES_ENV),
|
||||
);
|
||||
});
|
||||
|
||||
it("names the resolved wildcard address when the spelling differs (fullwidth)", () => {
|
||||
// `HOST="0"` renders like `0`, so the message shows it resolves to 0.0.0.0.
|
||||
expect(() => resolveBindHostname({ HOST: "0" })).toThrow(
|
||||
/resolves to 0\.0\.0\.0/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add a resolved-address hint for an already-canonical bracketed HOST", () => {
|
||||
// `HOST="[::]"` is accepted-then-refused as the wildcard; the message must
|
||||
// not read "(resolves to [::])" — same value, just bracketed. Capture the
|
||||
// message outside try/catch so a stopped-throwing regression reports clearly.
|
||||
let message = "";
|
||||
expect(() => {
|
||||
try {
|
||||
resolveBindHostname({ HOST: "[::]" });
|
||||
} catch (err) {
|
||||
message = (err as Error).message;
|
||||
throw err;
|
||||
}
|
||||
}).toThrow(BIND_ALL_INTERFACES_ENV);
|
||||
expect(message).not.toContain("resolves to");
|
||||
});
|
||||
|
||||
it.each(["true", "TRUE", "1", " true "])(
|
||||
"allows 0.0.0.0 when the opt-in is %j",
|
||||
(flag) => {
|
||||
expect(
|
||||
resolveBindHostname({
|
||||
HOST: "0.0.0.0",
|
||||
[BIND_ALL_INTERFACES_ENV]: flag,
|
||||
}),
|
||||
).toBe("0.0.0.0");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["false", "0", "", "yes", "no"])(
|
||||
"still refuses 0.0.0.0 when the opt-in reads %j",
|
||||
(flag) => {
|
||||
expect(() =>
|
||||
resolveBindHostname({
|
||||
HOST: "0.0.0.0",
|
||||
[BIND_ALL_INTERFACES_ENV]: flag,
|
||||
}),
|
||||
).toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -42,6 +42,7 @@ describe("runWeb", () => {
|
|||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitCode: number | undefined;
|
||||
let catalogEnvSnapshot: string | undefined;
|
||||
let hostEnvSnapshot: string | undefined;
|
||||
const signalHandlers = new Map<string, () => void>();
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -54,6 +55,8 @@ describe("runWeb", () => {
|
|||
exitCode = undefined;
|
||||
catalogEnvSnapshot = process.env.MCP_CATALOG_PATH;
|
||||
delete process.env.MCP_CATALOG_PATH;
|
||||
hostEnvSnapshot = process.env.HOST;
|
||||
delete process.env.HOST;
|
||||
|
||||
logLines = [];
|
||||
warnLines = [];
|
||||
|
|
@ -98,6 +101,11 @@ describe("runWeb", () => {
|
|||
} else {
|
||||
process.env.MCP_CATALOG_PATH = catalogEnvSnapshot;
|
||||
}
|
||||
if (hostEnvSnapshot === undefined) {
|
||||
delete process.env.HOST;
|
||||
} else {
|
||||
process.env.HOST = hostEnvSnapshot;
|
||||
}
|
||||
});
|
||||
|
||||
async function expectServerStarted(
|
||||
|
|
@ -162,6 +170,19 @@ describe("runWeb", () => {
|
|||
expect(startHonoServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the bind-guard error as an actionable message, not a stack trace", async () => {
|
||||
// HOST=0.0.0.0 without the opt-in makes buildWebServerConfig throw; run-web
|
||||
// must catch it and print "Error: …" rather than let it bubble as a raw
|
||||
// stack trace from the launcher's top-level handler.
|
||||
process.env.HOST = "0.0.0.0";
|
||||
await expect(runWeb(["node", "run-web"])).rejects.toThrow("process.exit:1");
|
||||
expect(
|
||||
errorLines.some((l) => l.includes("DANGEROUSLY_BIND_ALL_INTERFACES")),
|
||||
).toBe(true);
|
||||
expect(startHonoServer).not.toHaveBeenCalled();
|
||||
expect(startViteDevServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts Vite when --dev is set", async () => {
|
||||
void runWeb(["node", "run-web", "--dev"]);
|
||||
await expectServerStarted(startViteDevServer);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,58 @@ import { createServer, type Server } from "node:http";
|
|||
import {
|
||||
createSandboxController,
|
||||
resolveSandboxPort,
|
||||
sandboxFrameAncestors,
|
||||
} from "../../../../server/sandbox-controller.js";
|
||||
|
||||
describe("sandboxFrameAncestors", () => {
|
||||
it("derives the directive from the provided allow-list", () => {
|
||||
expect(
|
||||
sandboxFrameAncestors([
|
||||
"http://192.168.1.50:6274",
|
||||
"https://inspector.example.com",
|
||||
]),
|
||||
).toBe(
|
||||
"frame-ancestors http://192.168.1.50:6274 https://inspector.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([[undefined], [[]]])(
|
||||
"falls back to the loopback family when the list is %j",
|
||||
(origins) => {
|
||||
// No `[::1]` source — a bracketed IPv6 literal is not a valid CSP
|
||||
// host-source (see sandbox-controller.ts).
|
||||
expect(sandboxFrameAncestors(origins as string[] | undefined)).toBe(
|
||||
"frame-ancestors http://127.0.0.1:* http://localhost:*",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("drops malformed and IPv6-literal entries that can't be valid CSP sources", () => {
|
||||
// A newline would make writeHead throw ERR_INVALID_CHAR; a ';' would inject
|
||||
// extra directives; a bracketed IPv6 literal isn't a valid CSP host-source.
|
||||
// Only the well-formed origin survives.
|
||||
expect(
|
||||
sandboxFrameAncestors([
|
||||
"http://good.example:6274",
|
||||
"http://a:1; sandbox",
|
||||
"http://b:2\nX-Evil: 1",
|
||||
"http://[::1]:6274",
|
||||
"http://*.example.com", // a wildcard would widen the embedder set
|
||||
"not a url",
|
||||
]),
|
||||
).toBe("frame-ancestors http://good.example:6274");
|
||||
});
|
||||
|
||||
it("falls back to loopback when every entry is malformed or IPv6-literal", () => {
|
||||
expect(
|
||||
sandboxFrameAncestors(["http://a:1; sandbox", "http://[::1]:6274"]),
|
||||
).toBe("frame-ancestors http://127.0.0.1:* http://localhost:*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSandboxPort", () => {
|
||||
let envSnapshot: { mcp?: string; server?: string };
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot = {
|
||||
|
|
@ -15,9 +63,12 @@ describe("resolveSandboxPort", () => {
|
|||
};
|
||||
delete process.env.MCP_SANDBOX_PORT;
|
||||
delete process.env.SERVER_PORT;
|
||||
// A set-but-invalid MCP_SANDBOX_PORT warns; keep it off the test console.
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
if (envSnapshot.mcp === undefined) delete process.env.MCP_SANDBOX_PORT;
|
||||
else process.env.MCP_SANDBOX_PORT = envSnapshot.mcp;
|
||||
if (envSnapshot.server === undefined) delete process.env.SERVER_PORT;
|
||||
|
|
@ -65,6 +116,17 @@ describe("resolveSandboxPort", () => {
|
|||
process.env.MCP_SANDBOX_PORT = "-1";
|
||||
expect(resolveSandboxPort()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a partial-parse value (6274abc) rather than binding 6274", () => {
|
||||
process.env.MCP_SANDBOX_PORT = "6274abc";
|
||||
process.env.SERVER_PORT = "9100";
|
||||
expect(resolveSandboxPort()).toBe(9100);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range value (70000) so it can't crash listen", () => {
|
||||
process.env.MCP_SANDBOX_PORT = "70000";
|
||||
expect(resolveSandboxPort()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSandboxController", () => {
|
||||
|
|
@ -85,7 +147,10 @@ describe("createSandboxController", () => {
|
|||
// container, so any default-src/connect-src here would intersect with and
|
||||
// override the per-app CSP baked into the inner document.
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toContain("frame-ancestors http://127.0.0.1:*");
|
||||
// Assert the FULL directive (toBe, not toContain) so re-adding a source —
|
||||
// e.g. the `http://[::1]:*` that F2 proved harmful — would fail the test.
|
||||
// No `[::1]` — a bracketed IPv6 literal is not a valid CSP host-source.
|
||||
expect(csp).toBe("frame-ancestors http://127.0.0.1:* http://localhost:*");
|
||||
expect(csp).not.toContain("default-src");
|
||||
expect(csp).not.toContain("connect-src");
|
||||
const body = await res.text();
|
||||
|
|
@ -97,6 +162,57 @@ describe("createSandboxController", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("serves a CSP derived from allowedOrigins (the shipped default path)", async () => {
|
||||
// Both real callers always pass allowedOrigins, so the header the product
|
||||
// actually serves is the derived exact-origin directive, not the fallback.
|
||||
const controller = createSandboxController({
|
||||
port: 0,
|
||||
allowedOrigins: [
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274", // dropped — not a valid CSP host-source
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { url } = await controller.start();
|
||||
const res = await fetch(url);
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toBe(
|
||||
"frame-ancestors http://localhost:6274 http://127.0.0.1:6274",
|
||||
);
|
||||
} finally {
|
||||
await controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises localhost in the sandbox URL for a wildcard bind", async () => {
|
||||
// 0.0.0.0 isn't reachable from the browser, but a wildcard bind serves
|
||||
// loopback — so the URL handed to the client (and printed in the banner)
|
||||
// uses localhost.
|
||||
const controller = createSandboxController({ port: 0, host: "0.0.0.0" });
|
||||
try {
|
||||
const { url, port } = await controller.start();
|
||||
expect(url).toBe(`http://localhost:${port}/sandbox`);
|
||||
} finally {
|
||||
await controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the canonical (unmapped) host in the sandbox URL", async () => {
|
||||
// Same canonicalization as the origin allow-list, so the served sandbox URL
|
||||
// is reachable — ::ffff:127.0.0.1 answers at 127.0.0.1, not [::ffff:...].
|
||||
const controller = createSandboxController({
|
||||
port: 0,
|
||||
host: "::ffff:127.0.0.1",
|
||||
});
|
||||
try {
|
||||
const { url, port } = await controller.start();
|
||||
expect(url).toBe(`http://127.0.0.1:${port}/sandbox`);
|
||||
} finally {
|
||||
await controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 404 for paths other than /sandbox", async () => {
|
||||
const controller = createSandboxController({ port: 0 });
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
buildWebServerConfig,
|
||||
buildWebServerConfigFromEnv,
|
||||
defaultAllowedOrigins,
|
||||
printServerBanner,
|
||||
webServerConfigToInitialPayload,
|
||||
type WebServerConfig,
|
||||
|
|
@ -20,6 +21,7 @@ const MUTATED_ENV_KEYS = [
|
|||
"CLIENT_PORT",
|
||||
"HOST",
|
||||
"DANGEROUSLY_OMIT_AUTH",
|
||||
"DANGEROUSLY_BIND_ALL_INTERFACES",
|
||||
"MCP_STORAGE_DIR",
|
||||
"ALLOWED_ORIGINS",
|
||||
"MCP_SANDBOX_PORT",
|
||||
|
|
@ -75,7 +77,11 @@ describe("buildWebServerConfigFromEnv", () => {
|
|||
expect(cfg.dangerouslyOmitAuth).toBe(false);
|
||||
expect(cfg.initialMcpConfig).toBeNull();
|
||||
expect(cfg.storageDir).toBeUndefined();
|
||||
expect(cfg.allowedOrigins).toEqual(["http://localhost:6274"]);
|
||||
expect(cfg.allowedOrigins).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274",
|
||||
]);
|
||||
expect(cfg.sandboxPort).toBe(0);
|
||||
expect(cfg.sandboxHost).toBe("localhost");
|
||||
expect(cfg.logger).toBeUndefined();
|
||||
|
|
@ -85,13 +91,54 @@ describe("buildWebServerConfigFromEnv", () => {
|
|||
expect(cfg.autoOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("honors CLIENT_PORT and HOST", () => {
|
||||
it("honors CLIENT_PORT and a loopback HOST", () => {
|
||||
process.env.CLIENT_PORT = "8123";
|
||||
process.env.HOST = "0.0.0.0";
|
||||
process.env.HOST = "127.0.0.1";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.port).toBe(8123);
|
||||
expect(cfg.hostname).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("refuses HOST=0.0.0.0 without the bind-all opt-in", () => {
|
||||
process.env.HOST = "0.0.0.0";
|
||||
expect(() => buildWebServerConfigFromEnv()).toThrow(
|
||||
/DANGEROUSLY_BIND_ALL_INTERFACES/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows HOST=0.0.0.0 when the bind-all opt-in is set", () => {
|
||||
process.env.CLIENT_PORT = "8123";
|
||||
process.env.HOST = "0.0.0.0";
|
||||
process.env.DANGEROUSLY_BIND_ALL_INTERFACES = "true";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.hostname).toBe("0.0.0.0");
|
||||
expect(cfg.allowedOrigins).toEqual(["http://0.0.0.0:8123"]);
|
||||
// A wildcard bind serves loopback, so the default allow-list is the
|
||||
// loopback trio (what a `docker run -p` browser actually sends) plus the
|
||||
// canonical wildcard pair (0.0.0.0 / [::]).
|
||||
expect(cfg.allowedOrigins).toEqual([
|
||||
"http://localhost:8123",
|
||||
"http://127.0.0.1:8123",
|
||||
"http://[::1]:8123",
|
||||
"http://0.0.0.0:8123",
|
||||
"http://[::]:8123",
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands a loopback HOST into all equivalent loopback origins", () => {
|
||||
// `localhost` resolves to either 127.0.0.1 or ::1 depending on the OS, and
|
||||
// Node/Vite may bind the IPv6 form — so a browser can send `Origin:
|
||||
// http://[::1]:PORT` even though the banner advertised `localhost`. The
|
||||
// default must accept all three so the DNS-rebinding guard doesn't 403 a
|
||||
// legitimate loopback connect (the exact bug behind an stdio server that
|
||||
// "should always work" failing with a 403 Invalid origin).
|
||||
process.env.HOST = "127.0.0.1";
|
||||
process.env.CLIENT_PORT = "6274";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.allowedOrigins).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears authToken when DANGEROUSLY_OMIT_AUTH is set even if AUTH_TOKEN is present", () => {
|
||||
|
|
@ -121,10 +168,107 @@ describe("buildWebServerConfigFromEnv", () => {
|
|||
expect(cfg.authToken).toBe("primary");
|
||||
});
|
||||
|
||||
it("parses ALLOWED_ORIGINS and filters empty entries", () => {
|
||||
process.env.ALLOWED_ORIGINS = "http://a,,http://b";
|
||||
it("parses ALLOWED_ORIGINS, trimming entries and filtering empties", () => {
|
||||
process.env.ALLOWED_ORIGINS = "http://a:1, , http://b:2 ";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.allowedOrigins).toEqual(["http://a", "http://b"]);
|
||||
expect(cfg.allowedOrigins).toEqual(["http://a:1", "http://b:2"]);
|
||||
});
|
||||
|
||||
it.each(["", " ", ","])(
|
||||
"falls back to the default (not an empty allow-all) when ALLOWED_ORIGINS is %j",
|
||||
(value) => {
|
||||
// An empty parsed list must NOT reach the middleware — it treats an empty
|
||||
// allow-list as allow-all, which would silently disable the origin guard.
|
||||
process.env.ALLOWED_ORIGINS = value;
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.allowedOrigins).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("canonicalizes ALLOWED_ORIGINS entries so copy-paste forms still match", () => {
|
||||
// Trailing slash, uppercase host, explicit default :80 — all normalized to
|
||||
// the canonical Origin the browser actually sends.
|
||||
process.env.ALLOWED_ORIGINS =
|
||||
"http://localhost:6274/, http://Example.COM:6274, http://myhost:80";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.allowedOrigins).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://example.com:6274",
|
||||
"http://myhost",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops unparseable ALLOWED_ORIGINS entries (and falls back if none survive)", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
process.env.ALLOWED_ORIGINS = "not a url, *, http://ok:1";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
// "not a url" / "*" throw; "http://ok:1" parses.
|
||||
expect(cfg.allowedOrigins).toEqual(["http://ok:1"]);
|
||||
|
||||
process.env.ALLOWED_ORIGINS = "not a url, also bad";
|
||||
const cfg2 = buildWebServerConfigFromEnv();
|
||||
expect(cfg2.allowedOrigins).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274",
|
||||
]);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops scheme-less / opaque ALLOWED_ORIGINS entries rather than allow-listing "null"', () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
// `new URL("localhost:6274").origin` is the literal "null" (not a throw) —
|
||||
// must be dropped, not allow-listed (it'd match a real `Origin: null`).
|
||||
process.env.ALLOWED_ORIGINS =
|
||||
"localhost:6274, file:///srv, http://real:1";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.allowedOrigins).toEqual(["http://real:1"]);
|
||||
|
||||
// All scheme-less → nothing survives → fail-closed fallback to default.
|
||||
process.env.ALLOWED_ORIGINS = "localhost:6274, myhost:8080";
|
||||
const cfg2 = buildWebServerConfigFromEnv();
|
||||
expect(cfg2.allowedOrigins).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274",
|
||||
]);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a wildcard ALLOWED_ORIGINS entry (works for CSP but never for the origin check)", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
process.env.ALLOWED_ORIGINS =
|
||||
"http://*.example.com:6274, http://real.example.com:6274";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.allowedOrigins).toEqual(["http://real.example.com:6274"]);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["0", "abc", "70000", "-1", "6274abc", "80.9"])(
|
||||
"rejects an unusable CLIENT_PORT %j with an actionable error",
|
||||
(value) => {
|
||||
process.env.CLIENT_PORT = value;
|
||||
expect(() => buildWebServerConfigFromEnv()).toThrow(/CLIENT_PORT/);
|
||||
},
|
||||
);
|
||||
|
||||
it("treats an empty CLIENT_PORT as unset (defaults to 6274)", () => {
|
||||
process.env.CLIENT_PORT = "";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.port).toBe(6274);
|
||||
});
|
||||
|
||||
it("resolves sandboxPort from MCP_SANDBOX_PORT", () => {
|
||||
|
|
@ -196,6 +340,110 @@ describe("buildWebServerConfigFromEnv", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("defaultAllowedOrigins", () => {
|
||||
it.each(["localhost", "127.0.0.1", "::1", "[::1]", "LOCALHOST"])(
|
||||
"expands the loopback host %s into all three loopback origins",
|
||||
(host) => {
|
||||
expect(defaultAllowedOrigins(host, 6274)).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns a single exact origin for a specific non-loopback host", () => {
|
||||
expect(defaultAllowedOrigins("192.168.1.50", 6274)).toEqual([
|
||||
"http://192.168.1.50:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
// A non-canonical spelling of a loopback address is canonicalized (the way the
|
||||
// browser canonicalizes it into `Origin`), so it's recognized as loopback and
|
||||
// gets the trio — not a single unmatchable entry.
|
||||
it.each([
|
||||
"127.1",
|
||||
"0x7f.0.0.1",
|
||||
"2130706433",
|
||||
"0:0:0:0:0:0:0:1",
|
||||
"::0001",
|
||||
"::ffff:127.0.0.1", // IPv4-mapped loopback — the socket answers on 127.0.0.1
|
||||
])("canonicalizes the loopback spelling %j and returns the trio", (host) => {
|
||||
expect(defaultAllowedOrigins(host, 6274)).toEqual([
|
||||
"http://localhost:6274",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://[::1]:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
it("unmaps an IPv4-mapped non-loopback host to its dotted form", () => {
|
||||
expect(defaultAllowedOrigins("::ffff:192.168.1.50", 6274)).toEqual([
|
||||
"http://192.168.1.50:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a distinct non-loopback address (127.0.0.2) as a single origin", () => {
|
||||
// 127.0.0.2 is canonical and a bind there doesn't serve 127.0.0.1, so the
|
||||
// single-origin branch is correct — only non-canonical *spellings* expand.
|
||||
expect(defaultAllowedOrigins("127.0.0.2", 6274)).toEqual([
|
||||
"http://127.0.0.2:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an empty host (canonicalUrlHost's non-URL fallback) as the wildcard", () => {
|
||||
// `new URL("http://")` throws, so canonicalUrlHost falls back to "" — which
|
||||
// isAllInterfacesHost treats as the wildcard. No `http://:PORT` garbage.
|
||||
expect(defaultAllowedOrigins("", 8123)).toEqual([
|
||||
"http://localhost:8123",
|
||||
"http://127.0.0.1:8123",
|
||||
"http://[::1]:8123",
|
||||
"http://0.0.0.0:8123",
|
||||
"http://[::]:8123",
|
||||
]);
|
||||
});
|
||||
|
||||
// Any wildcard spelling yields the loopback trio + the CANONICAL wildcard pair
|
||||
// (0.0.0.0 / [::]) — not the typed spelling, which the browser canonicalizes
|
||||
// away (HOST=0 / 0x0 / 0.0.0 all send http://0.0.0.0:PORT; ::0 sends [::]).
|
||||
it.each(["0.0.0.0", "::", "::0", "0", "0x0", "0.0.0"])(
|
||||
"returns the loopback trio + canonical wildcard pair for the all-interfaces host %j",
|
||||
(host) => {
|
||||
expect(defaultAllowedOrigins(host, 8123)).toEqual([
|
||||
"http://localhost:8123",
|
||||
"http://127.0.0.1:8123",
|
||||
"http://[::1]:8123",
|
||||
"http://0.0.0.0:8123",
|
||||
"http://[::]:8123",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("lowercases a non-loopback hostname to match the browser's Origin", () => {
|
||||
expect(defaultAllowedOrigins("Example.COM", 6274)).toEqual([
|
||||
"http://example.com:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the port from the origin when it's the http default (80)", () => {
|
||||
// Browsers drop :80 from the Origin header, and the guard is an exact
|
||||
// match, so an origin with :80 could never match a real request.
|
||||
expect(defaultAllowedOrigins("192.168.1.50", 80)).toEqual([
|
||||
"http://192.168.1.50",
|
||||
]);
|
||||
expect(defaultAllowedOrigins("localhost", 80)).toEqual([
|
||||
"http://localhost",
|
||||
"http://127.0.0.1",
|
||||
"http://[::1]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("brackets a non-loopback IPv6 literal so it's a valid origin", () => {
|
||||
expect(defaultAllowedOrigins("fe80::1", 6274)).toEqual([
|
||||
"http://[fe80::1]:6274",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWebServerConfig", () => {
|
||||
it("matches buildWebServerConfigFromEnv when initialMcpConfig is omitted", () => {
|
||||
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "shared";
|
||||
|
|
@ -239,7 +487,11 @@ describe("buildWebServerConfig", () => {
|
|||
const cfg = buildWebServerConfig({ initialMcpConfig });
|
||||
expect(cfg.port).toBe(7000);
|
||||
expect(cfg.initialMcpConfig).toEqual(initialMcpConfig);
|
||||
expect(cfg.allowedOrigins).toEqual(["http://localhost:7000"]);
|
||||
expect(cfg.allowedOrigins).toEqual([
|
||||
"http://localhost:7000",
|
||||
"http://127.0.0.1:7000",
|
||||
"http://[::1]:7000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves remote transport initialMcpConfig", () => {
|
||||
|
|
@ -368,6 +620,31 @@ describe("printServerBanner", () => {
|
|||
expect(url).toBe("http://localhost:6274");
|
||||
});
|
||||
|
||||
it("brackets an IPv6 bind host in the printed URL", () => {
|
||||
const cfg = baseConfig();
|
||||
cfg.hostname = "::1";
|
||||
const url = printServerBanner(cfg, 6274, "", undefined);
|
||||
expect(url).toBe("http://[::1]:6274");
|
||||
});
|
||||
|
||||
it("advertises localhost for a wildcard bind rather than the wildcard host", () => {
|
||||
const cfg = baseConfig();
|
||||
cfg.hostname = "0.0.0.0";
|
||||
const url = printServerBanner(cfg, 6274, "", undefined);
|
||||
expect(url).toBe("http://localhost:6274");
|
||||
});
|
||||
|
||||
it("advertises the canonical (unmapped) host so banner ⊆ allowedOrigins", () => {
|
||||
// An IPv4-mapped bind host: the allow-list emits the loopback trio (which
|
||||
// includes http://127.0.0.1), so the banner must advertise a member of it,
|
||||
// not the [::ffff:127.0.0.1] form (whose Origin is [::ffff:7f00:1]).
|
||||
const cfg = baseConfig();
|
||||
cfg.hostname = "::ffff:127.0.0.1";
|
||||
const url = printServerBanner(cfg, 6274, "", undefined);
|
||||
expect(url).toBe("http://127.0.0.1:6274");
|
||||
expect(defaultAllowedOrigins(cfg.hostname, 6274)).toContain(url);
|
||||
});
|
||||
|
||||
it("prints the sandbox URL when provided", () => {
|
||||
printServerBanner(baseConfig(), 6274, "tok", "http://sandbox:9999/sandbox");
|
||||
expect(
|
||||
|
|
|
|||
Loading…
Reference in a new issue