fix(mcp): classify HTTP binds from native address facts

Classify loopback from the actual bound IP only, then add the configured name to the Host/Origin allowlist. Remove DNS-name classification and the default/fallback that substituted configuration for binding evidence.

Validate the TCP address once before URL/allowlist consumers, preserve IPv4 and IPv6 alias behavior, and add negative controls for localhost names bound to non-loopback IPv4 and IPv6 addresses.
This commit is contained in:
gensokyo-dev committed 2026-10-19 14:05:23 +00:00
1 parent 898044cc96
commit ba73ccafd3
4 files changed
+47 -41

No files matched your search

+4 -3
View File
@@ -517,9 +517,10 @@ session; any other sessionless POST goes to the live transport, which refuses
it without disturbing the session. Requests that carry no valid session id are it without disturbing the session. Requests that carry no valid session id are
refused with `400` or `404` as the SDK transport defines. A bind is loopback by refused with `400` or `404` as the SDK transport defines. A bind is loopback by
address class, not spelling, and is classified from the address the listener address class, not spelling, and is classified from the address the listener
actually bound: any `127.0.0.0/8` address, `::1`, an IPv4-mapped loopback and actually bound: any `127.0.0.0/8` address, `::1`, or an IPv4-mapped loopback.
`localhost` names, including a host alias that resolves to one of them. On such Configured names, including `localhost` and its aliases, do not establish that
a bind, requests whose `Host` header is outside the SDK loopback allowlist plus the bound address is local; names are added only after classifying the actual IP.
On such a bind, requests whose `Host` header is outside the SDK loopback allowlist plus
the configured name and the bound address are refused with `403`, and a browser the configured name and the bound address are refused with `403`, and a browser
`Origin` outside that allowlist is refused the same way (a missing `Origin` `Origin` outside that allowlist is refused the same way (a missing `Origin`
passes); a non-loopback bind relaxes both checks and logs the relaxation. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, a target containing a raw fragment delimiter (`#`), or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Percent-encoded `%23` remains valid query data. Only a lone `initialize` can replace an open session; a batch that carries one is left to the transport's refusal. Scoped passes); a non-loopback bind relaxes both checks and logs the relaxation. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, a target containing a raw fragment delimiter (`#`), or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Percent-encoded `%23` remains valid query data. Only a lone `initialize` can replace an open session; a batch that carries one is left to the transport's refusal. Scoped
@@ -29,8 +29,8 @@ client registration keep stdio.
- Bridge node:http to web Request/Response in `src/server/http/`, streaming - Bridge node:http to web Request/Response in `src/server/http/`, streaming
response chunks as they are produced and turning a client disconnect into a response chunks as they are produced and turning a client disconnect into a
request abort and body cancellation. request abort and body cancellation.
- Classify a loopback bind by address class (`127.0.0.0/8`, `::1`, IPv4-mapped - Classify a loopback bind by the actual bound IP (`127.0.0.0/8`, `::1`, or
loopback, `localhost` names) from the address the listener bound, validate IPv4-mapped loopback), without using the configured DNS name as evidence; validate
the `Host` and `Origin` headers with the SDK loopback allowlist plus the the `Host` and `Origin` headers with the SDK loopback allowlist plus the
configured name and the bound address on such binds, and relax both for configured name and the bound address on such binds, and relax both for
other binds and log that relaxation. Refuse scoped IPv6 literals before other binds and log that relaxation. Refuse scoped IPv6 literals before
+29 -13
View File
@@ -276,22 +276,28 @@ it("reports a port that cannot be bound as a typed listen error naming the cause
it("keeps the SDK loopback allowlist, plus the bind's own hostname, for every loopback spelling", () => { it("keeps the SDK loopback allowlist, plus the bind's own hostname, for every loopback spelling", () => {
const sdk = ["localhost", "127.0.0.1", "[::1]"]; const sdk = ["localhost", "127.0.0.1", "[::1]"];
expect(hostHeaderAllowlist("127.0.0.1")).toEqual(sdk); expect(hostHeaderAllowlist("127.0.0.1", "127.0.0.1")).toEqual(sdk);
expect(hostHeaderAllowlist("localhost")).toEqual(sdk); expect(hostHeaderAllowlist("localhost", "127.0.0.1")).toEqual(sdk);
expect(hostHeaderAllowlist("LOCALHOST")).toEqual(sdk); expect(hostHeaderAllowlist("LOCALHOST", "::1")).toEqual(sdk);
expect(hostHeaderAllowlist("::1")).toEqual(sdk); expect(hostHeaderAllowlist("::1", "::1")).toEqual(sdk);
expect(hostHeaderAllowlist("0:0:0:0:0:0:0:1")).toEqual(sdk); expect(hostHeaderAllowlist("0:0:0:0:0:0:0:1", "::1")).toEqual(sdk);
expect(hostHeaderAllowlist("127.0.0.2")).toEqual([...sdk, "127.0.0.2"]); expect(hostHeaderAllowlist("127.0.0.2", "127.0.0.2")).toEqual([
expect(hostHeaderAllowlist("localhost.")).toEqual([...sdk, "localhost."]); ...sdk,
expect(hostHeaderAllowlist("::ffff:127.0.0.1")).toEqual([ "127.0.0.2",
]);
expect(hostHeaderAllowlist("localhost.", "127.0.0.1")).toEqual([
...sdk,
"localhost.",
]);
expect(hostHeaderAllowlist("::ffff:127.0.0.1", "::ffff:127.0.0.1")).toEqual([
...sdk, ...sdk,
"[::ffff:7f00:1]", "[::ffff:7f00:1]",
]); ]);
expect(hostHeaderAllowlist("192.0.2.10")).toBeUndefined(); expect(hostHeaderAllowlist("192.0.2.10", "192.0.2.10")).toBeUndefined();
expect(hostHeaderAllowlist("0.0.0.0")).toBeUndefined(); expect(hostHeaderAllowlist("0.0.0.0", "0.0.0.0")).toBeUndefined();
expect(hostHeaderAllowlist("::")).toBeUndefined(); expect(hostHeaderAllowlist("::", "::")).toBeUndefined();
expect(hostHeaderAllowlist("example.com")).toBeUndefined(); expect(hostHeaderAllowlist("example.com", "192.0.2.10")).toBeUndefined();
expect(hostHeaderAllowlist("not a host")).toBeUndefined(); expect(hostHeaderAllowlist("not a host", "192.0.2.10")).toBeUndefined();
}); });
it("refuses requests once the slot is closed and joins an initialize that is still arming", async () => { it("refuses requests once the slot is closed and joins an initialize that is still arming", async () => {
@@ -460,6 +466,16 @@ it("classifies loopback from the bound address and lists the configured name bes
expect(hostHeaderAllowlist("dev.local", "192.0.2.10")).toBeUndefined(); expect(hostHeaderAllowlist("dev.local", "192.0.2.10")).toBeUndefined();
}); });
it.each([
["localhost", "192.0.2.10"],
["service.localhost", "2001:db8::10"],
])(
"does not classify configured %s as loopback when bound to %s",
(host, address) => {
expect(hostHeaderAllowlist(host, address)).toBeUndefined();
},
);
it("refuses a scoped IPv6 bind before creating anything and keeps bind failures away from onerror", async () => { it("refuses a scoped IPv6 bind before creating anything and keeps bind failures away from onerror", async () => {
let factoryCalls = 0; let factoryCalls = 0;
const onerror = (): void => { const onerror = (): void => {
+12 -23
View File
@@ -17,7 +17,7 @@ import {
type IncomingMessage, type IncomingMessage,
type ServerResponse, type ServerResponse,
} from "node:http"; } from "node:http";
import { isIPv4, isIPv6 } from "node:net"; import { isIPv4, isIPv6, type AddressInfo } from "node:net";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { import {
@@ -81,15 +81,8 @@ const normalizedHostname = (host: string): string | undefined => {
}; };
const isLoopbackIpv4 = (address: string): boolean => const isLoopbackIpv4 = (address: string): boolean =>
isIPv4(address) && address.startsWith("127."); isIPv4(address) && address.startsWith("127.");
/** Loopback by address class and localhost names, so no spelling of a local bind skips validation. */ /** Classify the native bound IP; configured DNS names are not binding evidence. */
const isLoopbackHostname = (hostname: string): boolean => { const isLoopbackAddress = (hostname: string): boolean => {
if (
hostname === "localhost" ||
hostname === "localhost." ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".localhost.")
)
return true;
if (isLoopbackIpv4(hostname)) return true; if (isLoopbackIpv4(hostname)) return true;
if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false; if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false;
const inner = hostname.slice(1, -1); const inner = hostname.slice(1, -1);
@@ -100,14 +93,15 @@ const isLoopbackHostname = (hostname: string): boolean => {
const hex = /^([0-9a-f]{1,4}):[0-9a-f]{1,4}$/u.exec(mapped); const hex = /^([0-9a-f]{1,4}):[0-9a-f]{1,4}$/u.exec(mapped);
return hex !== null && Number.parseInt(hex[1] ?? "0", 16) >> 8 === 0x7f; return hex !== null && Number.parseInt(hex[1] ?? "0", 16) >> 8 === 0x7f;
}; };
/** Host and Origin allowlist when the configured host or the address it bound is loopback, including both names; undefined relaxes validation. */ /** Classify the bound address first, then allow its address and configured name. */
export const hostHeaderAllowlist = ( export const hostHeaderAllowlist = (
host: string, host: string,
boundAddress = host, boundAddress: string,
): string[] | undefined => { ): string[] | undefined => {
const names = [normalizedHostname(host), normalizedHostname(boundAddress)]; const bound = normalizedHostname(boundAddress);
if (bound === undefined || !isLoopbackAddress(bound)) return undefined;
const names = [normalizedHostname(host), bound];
const known = names.filter((name): name is string => name !== undefined); const known = names.filter((name): name is string => name !== undefined);
if (!known.some((name) => isLoopbackHostname(name))) return undefined;
return [...new Set([...localhostAllowedHostnames(), ...known])]; return [...new Set([...localhostAllowedHostnames(), ...known])];
}; };
@@ -436,11 +430,7 @@ export class McpSessionSlot {
} }
} }
const endpointUrl = ( const endpointUrl = (address: AddressInfo): string => {
address: ReturnType<ReturnType<typeof createHttpServer>["address"]>,
): string => {
if (address === null || typeof address === "string")
throw new TypeError("MCP HTTP listener has no TCP address");
const host = const host =
address.family === "IPv6" ? `[${address.address}]` : address.address; address.family === "IPv6" ? `[${address.address}]` : address.address;
return `http://${host}:${String(address.port)}${MCP_HTTP_PATH}`; return `http://${host}:${String(address.port)}${MCP_HTTP_PATH}`;
@@ -536,16 +526,15 @@ export const serveStreamableHttp = async (
const bound = httpServer.address(); const bound = httpServer.address();
let url: string; let url: string;
try { try {
if (bound === null || typeof bound === "string")
throw new TypeError("MCP HTTP listener has no TCP address");
url = endpointUrl(bound); url = endpointUrl(bound);
origin = new URL(url).origin; origin = new URL(url).origin;
allowedHosts = hostHeaderAllowlist(host, bound.address);
} catch (cause: unknown) { } catch (cause: unknown) {
await prepareTransportShutdown().catch(() => undefined); await prepareTransportShutdown().catch(() => undefined);
throw new StreamableHttpListenError(cause); throw new StreamableHttpListenError(cause);
} }
allowedHosts = hostHeaderAllowlist(
host,
bound !== null && typeof bound === "object" ? bound.address : host,
);
httpServer.on("request", (request, response) => { httpServer.on("request", (request, response) => {
void requests.handle(request, response).catch((cause: unknown) => { void requests.handle(request, response).catch((cause: unknown) => {
logger?.warn( logger?.warn(