Skip to content

Commit 5c989e9

Browse files
committed
feat: US-110 - Add SSRF protection — block private IPs and validate redirects
1 parent 824e0be commit 5c989e9

4 files changed

Lines changed: 300 additions & 33 deletions

File tree

packages/secure-exec-node/src/driver.ts

Lines changed: 133 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as dns from "node:dns";
22
import * as fs from "node:fs/promises";
3+
import * as net from "node:net";
34
import type { AddressInfo } from "node:net";
45
import * as http from "node:http";
56
import * as https from "node:https";
@@ -180,6 +181,76 @@ function normalizeLoopbackHostname(hostname?: string): string {
180181
);
181182
}
182183

184+
/** Check whether an IP address falls in a private/reserved range (SSRF protection). */
185+
export function isPrivateIp(ip: string): boolean {
186+
// Normalize IPv4-mapped IPv6 (::ffff:a.b.c.d → a.b.c.d)
187+
const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
188+
189+
if (net.isIPv4(normalized)) {
190+
const parts = normalized.split(".").map(Number);
191+
const [a, b] = parts;
192+
return (
193+
a === 10 || // 10.0.0.0/8
194+
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12
195+
(a === 192 && b === 168) || // 192.168.0.0/16
196+
a === 127 || // 127.0.0.0/8
197+
(a === 169 && b === 254) || // 169.254.0.0/16 (link-local)
198+
a === 0 || // 0.0.0.0/8
199+
(a >= 224 && a <= 239) || // 224.0.0.0/4 (multicast)
200+
(a >= 240) // 240.0.0.0/4 (reserved)
201+
);
202+
}
203+
204+
if (net.isIPv6(normalized)) {
205+
const lower = normalized.toLowerCase();
206+
return (
207+
lower === "::1" || // loopback
208+
lower === "::" || // unspecified
209+
lower.startsWith("fc") || // fc00::/7 (ULA)
210+
lower.startsWith("fd") || // fc00::/7 (ULA)
211+
lower.startsWith("fe80") || // fe80::/10 (link-local)
212+
lower.startsWith("ff") // ff00::/8 (multicast)
213+
);
214+
}
215+
216+
return false;
217+
}
218+
219+
/** Resolve hostname to IP and block private/reserved ranges (SSRF protection). */
220+
async function assertNotPrivateHost(url: string): Promise<void> {
221+
const parsed = new URL(url);
222+
// Non-network schemes don't need SSRF checks
223+
if (parsed.protocol === "data:" || parsed.protocol === "blob:") return;
224+
225+
const hostname = parsed.hostname;
226+
// Strip brackets from IPv6 literals
227+
const bare = hostname.startsWith("[") && hostname.endsWith("]")
228+
? hostname.slice(1, -1)
229+
: hostname;
230+
231+
// If hostname is already an IP literal, check directly
232+
if (net.isIP(bare)) {
233+
if (isPrivateIp(bare)) {
234+
throw new Error(`SSRF blocked: ${hostname} resolves to private IP`);
235+
}
236+
return;
237+
}
238+
239+
// Resolve DNS and check all addresses
240+
const address = await new Promise<string>((resolve, reject) => {
241+
dns.lookup(bare, (err, addr) => {
242+
if (err) reject(err);
243+
else resolve(addr);
244+
});
245+
});
246+
247+
if (isPrivateIp(address)) {
248+
throw new Error(`SSRF blocked: ${hostname} resolves to private IP ${address}`);
249+
}
250+
}
251+
252+
const MAX_REDIRECTS = 20;
253+
183254
/**
184255
* Create a Node.js network adapter that provides real fetch, DNS, HTTP client,
185256
* and loopback-only HTTP server support. Binary responses are base64-encoded
@@ -286,42 +357,68 @@ export function createDefaultNetworkAdapter(): NetworkAdapter {
286357
},
287358

288359
async fetch(url, options) {
289-
const response = await fetch(url, {
290-
method: options?.method || "GET",
291-
headers: options?.headers,
292-
body: options?.body,
293-
});
294-
const headers: Record<string, string> = {};
295-
response.headers.forEach((v, k) => {
296-
headers[k] = v;
297-
});
360+
// SSRF: validate initial URL and manually follow redirects
361+
let currentUrl = url;
362+
let redirected = false;
363+
364+
for (let i = 0; i <= MAX_REDIRECTS; i++) {
365+
await assertNotPrivateHost(currentUrl);
366+
367+
const response = await fetch(currentUrl, {
368+
method: options?.method || "GET",
369+
headers: options?.headers,
370+
body: options?.body,
371+
redirect: "manual",
372+
});
373+
374+
// Follow redirects with re-validation
375+
const status = response.status;
376+
if (status === 301 || status === 302 || status === 303 || status === 307 || status === 308) {
377+
const location = response.headers.get("location");
378+
if (!location) break;
379+
currentUrl = new URL(location, currentUrl).href;
380+
redirected = true;
381+
// POST→GET for 301/302/303
382+
if (status === 301 || status === 302 || status === 303) {
383+
options = { ...options, method: "GET", body: undefined };
384+
}
385+
continue;
386+
}
387+
388+
const headers: Record<string, string> = {};
389+
response.headers.forEach((v, k) => {
390+
headers[k] = v;
391+
});
298392

299-
delete headers["content-encoding"];
300-
301-
const contentType = response.headers.get("content-type") || "";
302-
const isBinary =
303-
contentType.includes("octet-stream") ||
304-
contentType.includes("gzip") ||
305-
url.endsWith(".tgz");
306-
307-
let body: string;
308-
if (isBinary) {
309-
const buffer = await response.arrayBuffer();
310-
body = Buffer.from(buffer).toString("base64");
311-
headers["x-body-encoding"] = "base64";
312-
} else {
313-
body = await response.text();
393+
delete headers["content-encoding"];
394+
395+
const contentType = response.headers.get("content-type") || "";
396+
const isBinary =
397+
contentType.includes("octet-stream") ||
398+
contentType.includes("gzip") ||
399+
currentUrl.endsWith(".tgz");
400+
401+
let body: string;
402+
if (isBinary) {
403+
const buffer = await response.arrayBuffer();
404+
body = Buffer.from(buffer).toString("base64");
405+
headers["x-body-encoding"] = "base64";
406+
} else {
407+
body = await response.text();
408+
}
409+
410+
return {
411+
ok: response.ok,
412+
status: response.status,
413+
statusText: response.statusText,
414+
headers,
415+
body,
416+
url: currentUrl,
417+
redirected,
418+
};
314419
}
315420

316-
return {
317-
ok: response.ok,
318-
status: response.status,
319-
statusText: response.statusText,
320-
headers,
321-
body,
322-
url: response.url,
323-
redirected: response.redirected,
324-
};
421+
throw new Error("Too many redirects");
325422
},
326423

327424
async dnsLookup(hostname) {
@@ -337,6 +434,9 @@ export function createDefaultNetworkAdapter(): NetworkAdapter {
337434
},
338435

339436
async httpRequest(url, options) {
437+
// SSRF: block requests to private/reserved IPs
438+
await assertNotPrivateHost(url);
439+
340440
return new Promise((resolve, reject) => {
341441
const urlObj = new URL(url);
342442
const isHttps = urlObj.protocol === "https:";

packages/secure-exec-node/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export {
3636
createNodeRuntimeDriverFactory,
3737
NodeFileSystem,
3838
filterEnv,
39+
isPrivateIp,
3940
} from "./driver.js";
4041
export type {
4142
NodeDriverOptions,

packages/secure-exec/src/node/driver.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
NodeFileSystem,
77
NodeExecutionDriver,
88
filterEnv,
9+
isPrivateIp,
910
} from "@secure-exec/node";
1011
export type {
1112
NodeDriverOptions,
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createDefaultNetworkAdapter } from "../../../src/index.js";
3+
import { isPrivateIp } from "../../../src/node/driver.js";
4+
5+
describe("SSRF protection", () => {
6+
// ---------------------------------------------------------------
7+
// isPrivateIp — unit coverage for all reserved ranges
8+
// ---------------------------------------------------------------
9+
10+
describe("isPrivateIp", () => {
11+
it.each([
12+
["10.0.0.1", true], // 10.0.0.0/8
13+
["10.255.255.255", true],
14+
["172.16.0.1", true], // 172.16.0.0/12
15+
["172.31.255.255", true],
16+
["172.15.0.1", false], // just below range
17+
["172.32.0.1", false], // just above range
18+
["192.168.0.1", true], // 192.168.0.0/16
19+
["192.168.255.255", true],
20+
["127.0.0.1", true], // 127.0.0.0/8
21+
["127.255.255.255", true],
22+
["169.254.169.254", true], // 169.254.0.0/16 (link-local / metadata)
23+
["169.254.0.1", true],
24+
["0.0.0.0", true], // 0.0.0.0/8
25+
["224.0.0.1", true], // multicast
26+
["239.255.255.255", true],
27+
["240.0.0.1", true], // reserved
28+
["255.255.255.255", true],
29+
["8.8.8.8", false], // public
30+
["1.1.1.1", false],
31+
["142.250.80.46", false], // google
32+
])("IPv4 %s → %s", (ip, expected) => {
33+
expect(isPrivateIp(ip)).toBe(expected);
34+
});
35+
36+
it.each([
37+
["::1", true], // loopback
38+
["::", true], // unspecified
39+
["fc00::1", true], // ULA fc00::/7
40+
["fd12:3456::1", true], // ULA fd
41+
["fe80::1", true], // link-local
42+
["ff02::1", true], // multicast
43+
["2607:f8b0:4004::1", false], // public (google)
44+
])("IPv6 %s → %s", (ip, expected) => {
45+
expect(isPrivateIp(ip)).toBe(expected);
46+
});
47+
48+
it("detects IPv4-mapped IPv6 addresses", () => {
49+
expect(isPrivateIp("::ffff:10.0.0.1")).toBe(true);
50+
expect(isPrivateIp("::ffff:169.254.169.254")).toBe(true);
51+
expect(isPrivateIp("::ffff:8.8.8.8")).toBe(false);
52+
});
53+
});
54+
55+
// ---------------------------------------------------------------
56+
// Network adapter SSRF blocking
57+
// ---------------------------------------------------------------
58+
59+
describe("network adapter blocks private IPs", () => {
60+
const adapter = createDefaultNetworkAdapter();
61+
62+
it("fetch blocks metadata endpoint 169.254.169.254", async () => {
63+
await expect(
64+
adapter.fetch("http://169.254.169.254/latest/meta-data/", {}),
65+
).rejects.toThrow(/SSRF blocked/);
66+
});
67+
68+
it("fetch blocks 10.x private range", async () => {
69+
await expect(
70+
adapter.fetch("http://10.0.0.1/internal", {}),
71+
).rejects.toThrow(/SSRF blocked/);
72+
});
73+
74+
it("fetch blocks 192.168.x private range", async () => {
75+
await expect(
76+
adapter.fetch("http://192.168.1.1/admin", {}),
77+
).rejects.toThrow(/SSRF blocked/);
78+
});
79+
80+
it("httpRequest blocks metadata endpoint 169.254.169.254", async () => {
81+
await expect(
82+
adapter.httpRequest("http://169.254.169.254/latest/meta-data/", {}),
83+
).rejects.toThrow(/SSRF blocked/);
84+
});
85+
86+
it("httpRequest blocks localhost", async () => {
87+
await expect(
88+
adapter.httpRequest("http://127.0.0.1:9999/", {}),
89+
).rejects.toThrow(/SSRF blocked/);
90+
});
91+
92+
it("fetch allows data: URLs (no network)", async () => {
93+
const result = await adapter.fetch("data:text/plain,ssrf-test-ok", {});
94+
expect(result.ok).toBe(true);
95+
expect(result.body).toContain("ssrf-test-ok");
96+
});
97+
});
98+
99+
// ---------------------------------------------------------------
100+
// Redirect-to-private-IP blocking
101+
// ---------------------------------------------------------------
102+
103+
describe("redirect to private IP is blocked", () => {
104+
afterEach(() => {
105+
vi.restoreAllMocks();
106+
});
107+
108+
it("fetch blocks 302 redirect to private IP", async () => {
109+
// Mock global fetch to simulate a 302 redirect to a private IP
110+
const originalFetch = globalThis.fetch;
111+
const mockFetch = vi.fn().mockResolvedValueOnce(
112+
new Response(null, {
113+
status: 302,
114+
headers: { location: "http://169.254.169.254/latest/meta-data/" },
115+
}),
116+
);
117+
vi.stubGlobal("fetch", mockFetch);
118+
119+
const adapter = createDefaultNetworkAdapter();
120+
// Use a public-looking IP so the initial check passes
121+
await expect(
122+
adapter.fetch("http://8.8.8.8/redirect", {}),
123+
).rejects.toThrow(/SSRF blocked/);
124+
125+
vi.stubGlobal("fetch", originalFetch);
126+
});
127+
128+
it("fetch blocks 307 redirect to 10.x range", async () => {
129+
const originalFetch = globalThis.fetch;
130+
const mockFetch = vi.fn().mockResolvedValueOnce(
131+
new Response(null, {
132+
status: 307,
133+
headers: { location: "http://10.0.0.1/internal-api" },
134+
}),
135+
);
136+
vi.stubGlobal("fetch", mockFetch);
137+
138+
const adapter = createDefaultNetworkAdapter();
139+
await expect(
140+
adapter.fetch("http://8.8.8.8/redirect", {}),
141+
).rejects.toThrow(/SSRF blocked/);
142+
143+
vi.stubGlobal("fetch", originalFetch);
144+
});
145+
});
146+
147+
// ---------------------------------------------------------------
148+
// DNS rebinding — documented as known limitation
149+
// ---------------------------------------------------------------
150+
151+
describe("DNS rebinding", () => {
152+
it("known limitation: DNS rebinding after initial check is not blocked at the adapter level", () => {
153+
// DNS rebinding attacks involve a hostname that resolves to a safe public IP
154+
// on the first lookup (passing the SSRF check) but resolves to a private IP on
155+
// the subsequent connection. Fully mitigating this requires either:
156+
// - Pinning the resolved IP for the connection (not possible with native fetch)
157+
// - Using a custom DNS resolver with caching and TTL enforcement
158+
//
159+
// This is documented as a known limitation. The pre-flight DNS check still
160+
// provides defense in depth against most SSRF vectors including direct IP
161+
// access, redirect-based attacks, and static DNS entries.
162+
expect(true).toBe(true);
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)