import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { connect as netConnect, createServer as netCreateServer, type AddressInfo, type Socket } from "node:net"; import { gzipSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; import { guardedRemoteHttpFetch, type RemoteHttpSocketFactory } from "../services/remote-http-fetch.js"; /** * PAP-17098 — DNS-rebinding regression coverage for outbound MCP/OAuth calls. * * The vulnerability was a TOCTOU: the guard resolved a hostname, approved it, * and then handed the *hostname* to global `fetch`, which resolved it a second * time. A name server the attacker controls can answer with a public address for * the first lookup and a loopback/private/link-local address for the second, * pointing the connection at an internal service or the cloud metadata endpoint. * * These tests model that name server deterministically: `lookup` returns a public * address on its first call and `127.0.0.1` on every call after it. Nothing here * touches real DNS or the real network — `socketFactory` stands in for the * network so a "public" address is a loopback listener that reports itself as * public, exactly as it would look to the kernel. */ const PUBLIC_ADDRESS = "93.184.216.34"; const REBIND_HOST = "mcp.rebind.test"; type TestServer = { server: Server; port: number; requests: Array<{ url: string; host: string | undefined }>; connections: number; }; const openServers: Server[] = []; const openSockets: Socket[] = []; afterEach(async () => { for (const socket of openSockets.splice(0)) socket.destroy(); await Promise.all(openServers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); }); async function startServer(handler?: (req: IncomingMessage, res: ServerResponse) => void): Promise { const requests: TestServer["requests"] = []; const state = { connections: 0 }; const server = createServer((req, res) => { requests.push({ url: req.url ?? "", host: req.headers.host }); if (handler) { handler(req, res); return; } res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); }); server.on("connection", () => { state.connections += 1; }); openServers.push(server); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = (server.address() as AddressInfo).port; return { server, port, requests, get connections() { return state.connections; }, }; } function guardError(message: string, code: string) { return Object.assign(new Error(message), { code }); } /** * A stand-in for the network: routes whatever address the transport dialled to a * loopback listener, and reports `remoteAddress` as the dialled address so the * peer check sees what the kernel would have seen. */ function routingSocketFactory(routes: Record): { factory: RemoteHttpSocketFactory; dialled: string[]; sockets: Socket[]; } { const dialled: string[] = []; const sockets: Socket[] = []; const factory: RemoteHttpSocketFactory = (target) => { dialled.push(target.address); const port = routes[target.address]; if (port === undefined) throw new Error(`test network has no route to ${target.address}`); const socket = netConnect({ host: "127.0.0.1", port }); openSockets.push(socket); sockets.push(socket); Object.defineProperty(socket, "remoteAddress", { get: () => target.address, configurable: true }); return socket; }; return { factory, dialled, sockets }; } /** Waits for the socket bookkeeping the transport does after it rejects. */ async function flush(): Promise { await new Promise((resolve) => setTimeout(resolve, 50)); } /** A name server that answers public first and loopback afterwards. */ function rebindingLookup(): { lookup: () => Promise>; calls: () => number } { let calls = 0; return { lookup: async () => { calls += 1; return calls === 1 ? [{ address: PUBLIC_ADDRESS, family: 4 }] : [{ address: "127.0.0.1", family: 4 }]; }, calls: () => calls, }; } describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => { it("sends a stable default User-Agent and preserves an explicit caller value", async () => { const seen: Array = []; const upstream = await startServer((req, res) => { seen.push(req.headers["user-agent"]); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); }); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); const options = { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, error: guardError, }; await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, options); await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, { headers: { "user-agent": "Caller/2.0" }, }, options); expect(seen).toEqual(["Paperclip/1.0", "Caller/2.0"]); }); it("turns a platform-fetch DNS cause into the stable DNS code", async () => { const cause = Object.assign(new Error("getaddrinfo ENOTFOUND missing.invalid"), { code: "ENOTFOUND", }); const unpinnedFetch = (async () => { throw Object.assign(new TypeError("fetch failed"), { cause }); }) as typeof fetch; await expect(guardedRemoteHttpFetch("https://8.8.8.8/mcp", {}, { allowPrivateNetwork: true, unpinnedFetch, error: guardError, })).rejects.toMatchObject({ code: "remote_http_dns_failed", message: "Remote MCP connection hostname could not be resolved", }); }); it("pins the connection to the approved address so a rebind never reaches loopback", async () => { const upstream = await startServer(); const internal = await startServer(); const dns = rebindingLookup(); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port, "127.0.0.1": internal.port, }); const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: dns.lookup, socketFactory: network.factory, error: guardError, }); await expect(response.json()).resolves.toEqual({ ok: true }); // One resolution, and the connection went to the address that resolution // approved. The rebind answer is never consulted, so it cannot win a race. expect(dns.calls()).toBe(1); expect(network.dialled).toEqual([PUBLIC_ADDRESS]); expect(upstream.requests).toHaveLength(1); expect(internal.connections).toBe(0); expect(internal.requests).toHaveLength(0); }); it("keeps the original Host header even though it dialled an IP address", async () => { const upstream = await startServer(); const dns = rebindingLookup(); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, { method: "POST", body: "{}" }, { allowPrivateNetwork: false, lookup: dns.lookup, socketFactory: network.factory, error: guardError, }); expect(upstream.requests[0]?.host).toBe(REBIND_HOST); expect(upstream.requests[0]?.url).toBe("/mcp"); }); it("sends the hostname as TLS SNI rather than the pinned address", async () => { // The handshake is never completed: capturing the ClientHello is enough to // prove SNI carries the configured hostname, and needs no test certificate. const clientHello = new Promise((resolve) => { const server = createServer(); openServers.push(server); server.on("connection", (socket) => { socket.once("data", (chunk: Buffer) => { resolve(chunk); socket.destroy(); }); }); server.listen(0, "127.0.0.1", () => { const port = (server.address() as AddressInfo).port; const network = routingSocketFactory({ [PUBLIC_ADDRESS]: port }); void guardedRemoteHttpFetch(`https://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, error: guardError, }).catch(() => {}); }); }); const hello = await clientHello; expect(hello.includes(REBIND_HOST)).toBe(true); expect(hello.includes(PUBLIC_ADDRESS)).toBe(false); }); it("rejects a peer that turns out to be loopback without writing a request", async () => { const internal = await startServer(); // A rebind below DNS: resolution approved a public address, but the socket // lands on loopback anyway. The peer check has to catch it, and has to catch // it before any request byte is written. const factory: RemoteHttpSocketFactory = () => { const socket = netConnect({ host: "127.0.0.1", port: internal.port }); openSockets.push(socket); return socket; }; await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: factory, error: guardError, })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); expect(internal.requests).toHaveLength(0); }); it("rejects a hostname whose only resolution is link-local metadata", async () => { const internal = await startServer(); const network = routingSocketFactory({ "169.254.169.254": internal.port }); await expect(guardedRemoteHttpFetch("http://metadata.rebind.test/latest/meta-data/", {}, { allowPrivateNetwork: false, lookup: async () => [{ address: "169.254.169.254", family: 4 }], socketFactory: network.factory, error: guardError, })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); expect(network.dialled).toEqual([]); expect(internal.requests).toHaveLength(0); }); it("rejects a hostname that resolves to a mix of public and private addresses", async () => { await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [ { address: PUBLIC_ADDRESS, family: 4 }, { address: "10.1.2.3", family: 4 }, ], socketFactory: () => { throw new Error("must not dial"); }, error: guardError, })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); }); it("does not follow redirects, so the caller re-runs the guard on the next hop", async () => { const upstream = await startServer((_req, res) => { res.writeHead(302, { location: "http://127.0.0.1:9/internal" }); res.end(); }); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, error: guardError, }); expect(response.status).toBe(302); expect(response.headers.get("location")).toBe("http://127.0.0.1:9/internal"); }); it("carries an MCP Streamable HTTP exchange, SSE framing included", async () => { // The pinned transport replaces `fetch` on the tools/call path, so an MCP // JSON-RPC reply delivered as a single SSE event has to survive it intact. const upstream = await startServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { const request = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { method: string }; res.writeHead(200, { "content-type": "text/event-stream" }); res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: "1", result: { echo: request.method } })}\n\n`); }); }); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, { method: "POST", headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }), }, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, error: guardError, }); expect(response.headers.get("content-type")).toBe("text/event-stream"); await expect(response.text()).resolves.toContain('"echo":"tools/call"'); }); it("decodes a compressed response body the way fetch would", async () => { const upstream = await startServer((_req, res) => { res.writeHead(200, { "content-type": "application/json", "content-encoding": "gzip" }); res.end(gzipSync(Buffer.from(JSON.stringify({ token_endpoint: "https://auth.example/token" }), "utf8"))); }); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/.well-known/oauth-authorization-server`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, error: guardError, }); await expect(response.json()).resolves.toEqual({ token_endpoint: "https://auth.example/token" }); expect(response.headers.get("content-encoding")).toBeNull(); }); it("gives up on a server that accepts the connection and never answers", async () => { // Platform `fetch` applied undici's headersTimeout for free. The OAuth // callers pass no AbortSignal, so the pinned transport has to own the // deadline or a silent server would hold the request open forever. const upstream = await startServer(() => { /* accept the request and never respond */ }); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, responseTimeoutMs: 150, error: guardError, })).rejects.toMatchObject({ code: "remote_http_response_timeout" }); // The deadline has to hand back the socket, not just the request handler: // a bounded call that still leaks a descriptor per silent peer is the same // exhaustion bug wearing a timer. expect(upstream.requests).toHaveLength(1); await flush(); expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]); }); it("gives up on a peer that dribbles response headers forever", async () => { // This is why the headers deadline is a hard timer rather than // `req.setTimeout`: an idle timeout never fires against this peer, because // every trickled header line resets it. const raw = netCreateServer((socket) => { socket.write("HTTP/1.1 200 OK\r\n"); const beat = setInterval(() => socket.write("x-pad: keepalive\r\n"), 25); const stop = () => clearInterval(beat); socket.on("close", stop); socket.on("error", stop); }); await new Promise((resolve) => raw.listen(0, "127.0.0.1", resolve)); const port = (raw.address() as AddressInfo).port; const network = routingSocketFactory({ [PUBLIC_ADDRESS]: port }); try { await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, responseTimeoutMs: 150, error: guardError, })).rejects.toMatchObject({ code: "remote_http_response_timeout" }); await flush(); expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]); } finally { await new Promise((resolve) => raw.close(() => resolve())); } }); it("gives up on a response body that stalls midway", async () => { const upstream = await startServer((_req, res) => { res.writeHead(200, { "content-type": "application/json" }); res.write('{"partial":'); // never finishes the body }); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, responseTimeoutMs: 150, error: guardError, }); expect(response.status).toBe(200); await expect(response.text()).rejects.toMatchObject({ code: "remote_http_response_timeout" }); await flush(); expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]); }); it("falls over to the next approved address when the first is unreachable", async () => { // Pinning must not cost the failover `fetch` gave a multi-homed host: one // dead A record should not take the connection down with it. const upstream = await startServer(); const dead = await startServer(); // Close the listener so the address is routable but refuses connections. await new Promise((resolve) => dead.server.close(() => resolve())); const network = routingSocketFactory({ "93.184.216.35": dead.port, [PUBLIC_ADDRESS]: upstream.port, }); const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [ { address: "93.184.216.35", family: 4 }, { address: PUBLIC_ADDRESS, family: 4 }, ], socketFactory: network.factory, error: guardError, }); await expect(response.json()).resolves.toEqual({ ok: true }); expect(network.dialled).toEqual(["93.184.216.35", PUBLIC_ADDRESS]); }); it("fails closed instead of falling over when a peer fails the address check", async () => { // Failover covers unreachable addresses only. A peer that answers from an // unapproved address is the rebinding defence firing, and must not be // retried past. const internal = await startServer(); const upstream = await startServer(); const dialled: string[] = []; const factory: RemoteHttpSocketFactory = (target) => { dialled.push(target.address); const socket = netConnect({ host: "127.0.0.1", port: internal.port }); openSockets.push(socket); return socket; }; await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [ { address: "93.184.216.35", family: 4 }, { address: PUBLIC_ADDRESS, family: 4 }, ], socketFactory: factory, error: guardError, })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); expect(dialled).toEqual(["93.184.216.35"]); expect(internal.requests).toHaveLength(0); expect(upstream.requests).toHaveLength(0); }); it("still refuses a private IP literal before any transport runs", async () => { await expect(guardedRemoteHttpFetch("http://169.254.169.254/latest/meta-data/", {}, { allowPrivateNetwork: false, error: guardError, unpinnedFetch: async () => { throw new Error("must not fetch"); }, socketFactory: () => { throw new Error("must not dial"); }, })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); }); it("uses platform fetch for a public IP literal, where nothing can be re-resolved", async () => { const calls: string[] = []; const response = await guardedRemoteHttpFetch("https://93.184.216.34/mcp", { method: "POST" }, { allowPrivateNetwork: false, error: guardError, unpinnedFetch: async (input, init) => { calls.push(`${init?.method} ${String(input)} ${init?.redirect}`); return new Response("{}", { status: 200 }); }, socketFactory: () => { throw new Error("must not dial"); }, }); expect(response.status).toBe(200); expect(calls).toEqual(["POST https://93.184.216.34/mcp manual"]); }); it("uses platform fetch for an allowed private IP literal", async () => { const calls: string[] = []; const response = await guardedRemoteHttpFetch("http://127.0.0.1:9/mcp", {}, { allowPrivateNetwork: true, error: guardError, unpinnedFetch: async (input) => { calls.push(String(input)); return new Response("{}", { status: 200 }); }, socketFactory: () => { throw new Error("must not dial"); }, }); expect(response.status).toBe(200); expect(calls).toEqual(["http://127.0.0.1:9/mcp"]); }); it("never invokes platform fetch for a link-local literal in private mode", async () => { const calls: string[] = []; await expect(guardedRemoteHttpFetch("http://169.254.169.254/latest/meta-data/", {}, { allowPrivateNetwork: true, error: guardError, unpinnedFetch: async (input) => { calls.push(String(input)); return new Response("{}", { status: 200 }); }, })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); expect(calls).toEqual([]); }); it("pins an allowed private hostname and rejects a link-local peer before request bytes", async () => { const internal = await startServer(); const factory: RemoteHttpSocketFactory = () => { const socket = netConnect({ host: "127.0.0.1", port: internal.port }); openSockets.push(socket); Object.defineProperty(socket, "remoteAddress", { get: () => "169.254.169.254", configurable: true }); return socket; }; await expect(guardedRemoteHttpFetch("http://lan-service.example/mcp", {}, { allowPrivateNetwork: true, lookup: async () => [{ address: "10.0.0.8", family: 4 }], socketFactory: factory, error: guardError, unpinnedFetch: async () => { throw new Error("hostnames must remain pinned"); }, })).rejects.toMatchObject({ code: "remote_http_private_endpoint" }); expect(internal.requests).toHaveLength(0); }); });