mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 23:36:41 +00: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:
1 parent
898044cc96
commit
ba73ccafd3
4 files changed
+47
-41
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user