Skip to content

Commit 9140789

Browse files
fix: prevent DNS-rebinding TOCTOU in safeProxyFetch by pinning resolved IPs
Previously assertSafeProxyTarget resolved the target hostname and validated the IPs, but safeProxyFetch then passed the raw URL to node-fetch, which performed its own DNS lookup. In the window between the two resolutions an attacker who controls the domain's TTL could flip the record to 169.254.169.254 (cloud-metadata), causing fetch() to connect to the instance- metadata service even though the block-list check passed. Fix: assertSafeProxyTarget now returns the validated IP addresses. On each hop of safeProxyFetch we call createPinnedAgent() to build an http/https.Agent whose lookup hook unconditionally returns the pre-validated IP. node-fetch uses that hook instead of the OS resolver, closing the TOCTOU window entirely. Refactoring: isBlockedProxyAddress, assertSafeProxyTarget, and the new createPinnedAgent are extracted to server/src/proxy-security.ts so they can be unit-tested in isolation. A vitest suite is added to the server package (the first unit tests for this package) with 26 cases covering the block-list, DNS validation, IP-pinning, and the TOCTOU guarantee itself.
1 parent ac3c1a1 commit 9140789

5 files changed

Lines changed: 455 additions & 85 deletions

File tree

server/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,19 @@
2424
"build": "tsc && shx cp -R static build",
2525
"start": "node build/index.js",
2626
"dev": "tsx watch --clear-screen=false src/index.ts",
27-
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
27+
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL",
28+
"test": "vitest run",
29+
"test:watch": "vitest"
2830
},
2931
"devDependencies": {
3032
"@types/cors": "^2.8.19",
3133
"@types/express": "^5.0.0",
34+
"@types/node": "^22.0.0",
3235
"@types/shell-quote": "^1.7.5",
3336
"@types/ws": "^8.5.12",
3437
"tsx": "^4.19.0",
35-
"typescript": "^5.6.2"
38+
"typescript": "^5.6.2",
39+
"vitest": "^4.1.0"
3640
},
3741
"dependencies": {
3842
"@modelcontextprotocol/sdk": "^1.25.2",
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* Unit tests for proxy-security.ts
3+
*
4+
* Tests cover:
5+
* - isBlockedProxyAddress: IPv4, IPv6, IPv4-mapped IPv6 (hex + dotted), edge cases
6+
* - assertSafeProxyTarget: safe IPs, blocked IPs, literal-IP hosts, DNS errors
7+
* - createPinnedAgent: correct agent type, lookup always returns pinned IP
8+
* - TOCTOU guarantee: the pinned agent never invokes the OS resolver
9+
*/
10+
11+
import http from "node:http";
12+
import https from "node:https";
13+
import { vi, describe, it, expect, afterEach } from "vitest";
14+
15+
// Mock node:dns/promises before importing the module under test so that
16+
// assertSafeProxyTarget's dnsLookup is replaceable in each test.
17+
vi.mock("node:dns/promises", () => ({
18+
lookup: vi.fn(),
19+
}));
20+
21+
import * as dns from "node:dns/promises";
22+
import {
23+
isBlockedProxyAddress,
24+
assertSafeProxyTarget,
25+
createPinnedAgent,
26+
ProxyTargetError,
27+
} from "../proxy-security.js";
28+
29+
// Convenience cast — vitest doesn't know the mock shape yet.
30+
const mockLookup = dns.lookup as ReturnType<typeof vi.fn>;
31+
32+
afterEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
// ---------------------------------------------------------------------------
37+
// isBlockedProxyAddress
38+
// ---------------------------------------------------------------------------
39+
40+
describe("isBlockedProxyAddress", () => {
41+
describe("IPv4 link-local (169.254.0.0/16)", () => {
42+
it("blocks 169.254.169.254 (AWS metadata)", () => {
43+
expect(isBlockedProxyAddress("169.254.169.254")).toBe(true);
44+
});
45+
46+
it("blocks 169.254.0.1 (first address in range)", () => {
47+
expect(isBlockedProxyAddress("169.254.0.1")).toBe(true);
48+
});
49+
50+
it("blocks 169.254.255.255 (last address in range)", () => {
51+
expect(isBlockedProxyAddress("169.254.255.255")).toBe(true);
52+
});
53+
54+
it("allows 169.253.0.1 (just outside the range)", () => {
55+
expect(isBlockedProxyAddress("169.253.0.1")).toBe(false);
56+
});
57+
58+
it("allows 170.254.0.1 (just outside the range)", () => {
59+
expect(isBlockedProxyAddress("170.254.0.1")).toBe(false);
60+
});
61+
62+
it("allows loopback 127.0.0.1", () => {
63+
expect(isBlockedProxyAddress("127.0.0.1")).toBe(false);
64+
});
65+
66+
it("allows a public IP", () => {
67+
expect(isBlockedProxyAddress("93.184.216.34")).toBe(false);
68+
});
69+
});
70+
71+
describe("IPv6 link-local (fe80::/10)", () => {
72+
it("blocks fe80::1", () => {
73+
expect(isBlockedProxyAddress("fe80::1")).toBe(true);
74+
});
75+
76+
it("blocks fe80::aabb:ccdd (arbitrary link-local)", () => {
77+
expect(isBlockedProxyAddress("fe80::aabb:ccdd")).toBe(true);
78+
});
79+
80+
it("allows ::1 (loopback)", () => {
81+
expect(isBlockedProxyAddress("::1")).toBe(false);
82+
});
83+
84+
it("allows 2001:db8::1 (documentation range)", () => {
85+
expect(isBlockedProxyAddress("2001:db8::1")).toBe(false);
86+
});
87+
});
88+
89+
describe("AWS IPv6 IMDS (fd00:ec2::254)", () => {
90+
it("blocks fd00:ec2::254 exactly", () => {
91+
expect(isBlockedProxyAddress("fd00:ec2::254")).toBe(true);
92+
});
93+
94+
it("allows fd00:ec2::255 (adjacent address)", () => {
95+
expect(isBlockedProxyAddress("fd00:ec2::255")).toBe(false);
96+
});
97+
});
98+
99+
describe("IPv4-mapped IPv6 variants of 169.254.169.254", () => {
100+
it("blocks dotted form ::ffff:169.254.169.254", () => {
101+
expect(isBlockedProxyAddress("::ffff:169.254.169.254")).toBe(true);
102+
});
103+
104+
it("blocks hex form ::ffff:a9fe:a9fe (WHATWG URL serialization)", () => {
105+
expect(isBlockedProxyAddress("::ffff:a9fe:a9fe")).toBe(true);
106+
});
107+
108+
it("allows IPv4-mapped loopback ::ffff:127.0.0.1", () => {
109+
expect(isBlockedProxyAddress("::ffff:127.0.0.1")).toBe(false);
110+
});
111+
});
112+
113+
describe("non-IP strings", () => {
114+
it("allows empty string (not an IP)", () => {
115+
expect(isBlockedProxyAddress("")).toBe(false);
116+
});
117+
118+
it("allows hostname string (not an IP)", () => {
119+
expect(isBlockedProxyAddress("example.com")).toBe(false);
120+
});
121+
});
122+
});
123+
124+
// ---------------------------------------------------------------------------
125+
// assertSafeProxyTarget
126+
// ---------------------------------------------------------------------------
127+
128+
describe("assertSafeProxyTarget", () => {
129+
it("resolves and allows a safe hostname", async () => {
130+
mockLookup.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]);
131+
132+
const addrs = await assertSafeProxyTarget(new URL("http://example.com/"));
133+
expect(addrs).toEqual(["93.184.216.34"]);
134+
expect(mockLookup).toHaveBeenCalledWith("example.com", { all: true });
135+
});
136+
137+
it("throws ProxyTargetError when host resolves to blocked IP", async () => {
138+
mockLookup.mockResolvedValueOnce([
139+
{ address: "169.254.169.254", family: 4 },
140+
]);
141+
142+
await expect(
143+
assertSafeProxyTarget(new URL("http://evil.example.com/")),
144+
).rejects.toThrow(ProxyTargetError);
145+
});
146+
147+
it("throws ProxyTargetError when any resolved IP is blocked (mixed results)", async () => {
148+
mockLookup.mockResolvedValueOnce([
149+
{ address: "93.184.216.34", family: 4 },
150+
{ address: "169.254.169.254", family: 4 },
151+
]);
152+
153+
await expect(
154+
assertSafeProxyTarget(new URL("http://dual.example.com/")),
155+
).rejects.toThrow(ProxyTargetError);
156+
});
157+
158+
it("throws ProxyTargetError when DNS lookup fails", async () => {
159+
mockLookup.mockRejectedValueOnce(new Error("ENOTFOUND"));
160+
161+
await expect(
162+
assertSafeProxyTarget(new URL("http://nonexistent.invalid/")),
163+
).rejects.toThrow(ProxyTargetError);
164+
});
165+
166+
it("skips DNS lookup for literal IPv4 hosts", async () => {
167+
const addrs = await assertSafeProxyTarget(
168+
new URL("http://127.0.0.1/path"),
169+
);
170+
expect(addrs).toEqual(["127.0.0.1"]);
171+
expect(mockLookup).not.toHaveBeenCalled();
172+
});
173+
174+
it("throws ProxyTargetError for literal blocked IPv4", async () => {
175+
await expect(
176+
assertSafeProxyTarget(new URL("http://169.254.169.254/")),
177+
).rejects.toThrow(ProxyTargetError);
178+
expect(mockLookup).not.toHaveBeenCalled();
179+
});
180+
181+
it("skips DNS lookup for literal IPv6 hosts", async () => {
182+
const addrs = await assertSafeProxyTarget(new URL("http://[::1]/"));
183+
expect(addrs).toEqual(["::1"]);
184+
expect(mockLookup).not.toHaveBeenCalled();
185+
});
186+
187+
it("returns all validated addresses so caller can pick one for pinning", async () => {
188+
mockLookup.mockResolvedValueOnce([
189+
{ address: "192.0.2.1", family: 4 },
190+
{ address: "192.0.2.2", family: 4 },
191+
]);
192+
193+
const addrs = await assertSafeProxyTarget(new URL("http://multi.example/"));
194+
expect(addrs).toHaveLength(2);
195+
expect(addrs).toContain("192.0.2.1");
196+
expect(addrs).toContain("192.0.2.2");
197+
});
198+
});
199+
200+
// ---------------------------------------------------------------------------
201+
// createPinnedAgent
202+
// ---------------------------------------------------------------------------
203+
204+
describe("createPinnedAgent", () => {
205+
it("returns an http.Agent for http: protocol", () => {
206+
const agent = createPinnedAgent("http:", "127.0.0.1");
207+
expect(agent).toBeInstanceOf(http.Agent);
208+
expect(agent).not.toBeInstanceOf(https.Agent);
209+
});
210+
211+
it("returns an https.Agent for https: protocol", () => {
212+
const agent = createPinnedAgent("https:", "127.0.0.1");
213+
expect(agent).toBeInstanceOf(https.Agent);
214+
});
215+
216+
it("pinned lookup always returns the IPv4 address regardless of queried hostname", () =>
217+
new Promise<void>((resolve) => {
218+
const agent = createPinnedAgent("http:", "192.0.2.99");
219+
const lookup = (agent as http.Agent & { options: { lookup: Function } })
220+
.options.lookup;
221+
222+
lookup(
223+
"example.com",
224+
{},
225+
(err: Error | null, address: string, family: number) => {
226+
expect(err).toBeNull();
227+
expect(address).toBe("192.0.2.99");
228+
expect(family).toBe(4);
229+
resolve();
230+
},
231+
);
232+
}));
233+
234+
it("pinned lookup always returns the IPv6 address and family 6", () =>
235+
new Promise<void>((resolve) => {
236+
const agent = createPinnedAgent("http:", "2001:db8::1");
237+
const lookup = (agent as http.Agent & { options: { lookup: Function } })
238+
.options.lookup;
239+
240+
lookup(
241+
"example.com",
242+
{},
243+
(err: Error | null, address: string, family: number) => {
244+
expect(err).toBeNull();
245+
expect(address).toBe("2001:db8::1");
246+
expect(family).toBe(6);
247+
resolve();
248+
},
249+
);
250+
}));
251+
252+
it("TOCTOU guarantee: lookup never invokes the OS resolver", () => {
253+
const agent = createPinnedAgent("http:", "10.0.0.1");
254+
const lookup = (agent as http.Agent & { options: { lookup: Function } })
255+
.options.lookup;
256+
257+
const osDnsLookup = vi.fn();
258+
lookup("any-hostname.example", {}, osDnsLookup);
259+
260+
// osDnsLookup was called as the callback, not as a resolver —
261+
// the pinned implementation calls it synchronously with the fixed IP.
262+
expect(osDnsLookup).toHaveBeenCalledTimes(1);
263+
expect(osDnsLookup).toHaveBeenCalledWith(null, "10.0.0.1", 4);
264+
});
265+
});

0 commit comments

Comments
 (0)