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

View file

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

View file

@ -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);

View file

@ -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 {

View file

@ -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(