mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
assertPublicRemoteHttpEndpoint resolved an operator-supplied 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 guard's lookup and a loopback, RFC 1918 or link-local address for the connection, so an authenticated public deployment could be walked onto internal services or cloud metadata. The guard now returns the address set it approved, and guardedRemoteHttpFetch dials that address itself: one resolution, no second lookup below it, the original hostname kept for Host, TLS SNI and certificate identity, and a peer-address check before a single request byte (or ClientHello) is written. Redirects stay manual so the caller re-runs the whole guard on every hop it follows. Wired into all three guard-then-fetch sites: OAuth discovery/registration/ token exchange, catalog tools/list, and the gateway tools/call path. Two cases keep platform fetch, both because there is nothing to pin: deployments that allow private endpoints, and URLs that already carry an IP literal. Regression coverage models the rebinding name server deterministically — public on the first answer, 127.0.0.1 after it — and proves the request lands on the approved address while the loopback listener sees no connection at all. Reintroducing either half of the fix (a second lookup at dial time, or dropping the peer check) turns those tests red. Residual risk unchanged: egress allowlisting and network policy are still recommended as defence in depth. Refs PAP-17098
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
import { connect as netConnect, 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<void>((resolve) => server.close(() => resolve()))));
|
|
});
|
|
|
|
async function startServer(handler?: (req: IncomingMessage, res: ServerResponse) => void): Promise<TestServer> {
|
|
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<void>((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<string, number>): {
|
|
factory: RemoteHttpSocketFactory;
|
|
dialled: string[];
|
|
} {
|
|
const dialled: string[] = [];
|
|
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);
|
|
Object.defineProperty(socket, "remoteAddress", { get: () => target.address, configurable: true });
|
|
return socket;
|
|
};
|
|
return { factory, dialled };
|
|
}
|
|
|
|
/** A name server that answers public first and loopback afterwards. */
|
|
function rebindingLookup(): { lookup: () => Promise<Array<{ address: string; family: number }>>; 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("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<Buffer>((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("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 when the deployment allows private endpoints", async () => {
|
|
// Nothing to pin: an operator who is allowed to point at 127.0.0.1 directly
|
|
// gains nothing from rebinding, so this mode keeps `fetch` semantics.
|
|
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"]);
|
|
});
|
|
});
|