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
2026-10-19 14:05:23 +00:00
parent 898044cc96
commit ba73ccafd3
4 changed files with 47 additions and 41 deletions
+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
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
actually bound: any `127.0.0.0/8` address, `::1`, an IPv4-mapped loopback and
`localhost` names, including a host alias that resolves to one of them. On such
a bind, requests whose `Host` header is outside the SDK loopback allowlist plus
actually bound: any `127.0.0.0/8` address, `::1`, or an IPv4-mapped loopback.
Configured names, including `localhost` and its aliases, do not establish that
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
`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
@@ -29,8 +29,8 @@ client registration keep stdio.
- 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
request abort and body cancellation.
- Classify a loopback bind by address class (`127.0.0.0/8`, `::1`, IPv4-mapped
loopback, `localhost` names) from the address the listener bound, validate
- Classify a loopback bind by the actual bound IP (`127.0.0.0/8`, `::1`, or
IPv4-mapped loopback), without using the configured DNS name as evidence; validate
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
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", () => {
const sdk = ["localhost", "127.0.0.1", "[::1]"];
expect(hostHeaderAllowlist("127.0.0.1")).toEqual(sdk);
expect(hostHeaderAllowlist("localhost")).toEqual(sdk);
expect(hostHeaderAllowlist("LOCALHOST")).toEqual(sdk);
expect(hostHeaderAllowlist("::1")).toEqual(sdk);
expect(hostHeaderAllowlist("0:0:0:0:0:0:0:1")).toEqual(sdk);
expect(hostHeaderAllowlist("127.0.0.2")).toEqual([...sdk, "127.0.0.2"]);
expect(hostHeaderAllowlist("localhost.")).toEqual([...sdk, "localhost."]);
expect(hostHeaderAllowlist("::ffff:127.0.0.1")).toEqual([
expect(hostHeaderAllowlist("127.0.0.1", "127.0.0.1")).toEqual(sdk);
expect(hostHeaderAllowlist("localhost", "127.0.0.1")).toEqual(sdk);
expect(hostHeaderAllowlist("LOCALHOST", "::1")).toEqual(sdk);
expect(hostHeaderAllowlist("::1", "::1")).toEqual(sdk);
expect(hostHeaderAllowlist("0:0:0:0:0:0:0:1", "::1")).toEqual(sdk);
expect(hostHeaderAllowlist("127.0.0.2", "127.0.0.2")).toEqual([
...sdk,
"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,
"[::ffff:7f00:1]",
]);
expect(hostHeaderAllowlist("192.0.2.10")).toBeUndefined();
expect(hostHeaderAllowlist("0.0.0.0")).toBeUndefined();
expect(hostHeaderAllowlist("::")).toBeUndefined();
expect(hostHeaderAllowlist("example.com")).toBeUndefined();
expect(hostHeaderAllowlist("not a host")).toBeUndefined();
expect(hostHeaderAllowlist("192.0.2.10", "192.0.2.10")).toBeUndefined();
expect(hostHeaderAllowlist("0.0.0.0", "0.0.0.0")).toBeUndefined();
expect(hostHeaderAllowlist("::", "::")).toBeUndefined();
expect(hostHeaderAllowlist("example.com", "192.0.2.10")).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 () => {
@@ -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();
});
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 () => {
let factoryCalls = 0;
const onerror = (): void => {
+12 -23
View File
@@ -17,7 +17,7 @@ import {
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { isIPv4, isIPv6 } from "node:net";
import { isIPv4, isIPv6, type AddressInfo } from "node:net";
import type { Logger } from "pino";
import {
@@ -81,15 +81,8 @@ const normalizedHostname = (host: string): string | undefined => {
};
const isLoopbackIpv4 = (address: string): boolean =>
isIPv4(address) && address.startsWith("127.");
/** Loopback by address class and localhost names, so no spelling of a local bind skips validation. */
const isLoopbackHostname = (hostname: string): boolean => {
if (
hostname === "localhost" ||
hostname === "localhost." ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".localhost.")
)
return true;
/** Classify the native bound IP; configured DNS names are not binding evidence. */
const isLoopbackAddress = (hostname: string): boolean => {
if (isLoopbackIpv4(hostname)) return true;
if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false;
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);
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 = (
host: string,
boundAddress = host,
boundAddress: string,
): 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);
if (!known.some((name) => isLoopbackHostname(name))) return undefined;
return [...new Set([...localhostAllowedHostnames(), ...known])];
};
@@ -436,11 +430,7 @@ export class McpSessionSlot {
}
}
const endpointUrl = (
address: ReturnType<ReturnType<typeof createHttpServer>["address"]>,
): string => {
if (address === null || typeof address === "string")
throw new TypeError("MCP HTTP listener has no TCP address");
const endpointUrl = (address: AddressInfo): string => {
const host =
address.family === "IPv6" ? `[${address.address}]` : address.address;
return `http://${host}:${String(address.port)}${MCP_HTTP_PATH}`;
@@ -536,16 +526,15 @@ export const serveStreamableHttp = async (
const bound = httpServer.address();
let url: string;
try {
if (bound === null || typeof bound === "string")
throw new TypeError("MCP HTTP listener has no TCP address");
url = endpointUrl(bound);
origin = new URL(url).origin;
allowedHosts = hostHeaderAllowlist(host, bound.address);
} catch (cause: unknown) {
await prepareTransportShutdown().catch(() => undefined);
throw new StreamableHttpListenError(cause);
}
allowedHosts = hostHeaderAllowlist(
host,
bound !== null && typeof bound === "object" ? bound.address : host,
);
httpServer.on("request", (request, response) => {
void requests.handle(request, response).catch((cause: unknown) => {
logger?.warn(