mirror of
https://github.com/morluto/rea
synced 2026-10-28 23:11:11 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user