Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ssrf-guard.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ssrf-guard.test.tsBlame402 lines · 1 contributor
bc519aeClaude1/**
2 * SSRF guard tests — P1 security hardening (BUILD_BIBLE §7).
3 *
4 * Pins down `src/lib/ssrf-guard.ts`:
5 * - isPrivateAddress(): every reserved IPv4 range (incl. the
6 * decimal/hex/octal single-integer encodings), IPv6 private forms,
7 * and the localhost/.local/.internal hostname conventions
8 * - assertPublicUrl(): scheme allow-list, private-host rejection, and
9 * the SSRF_ALLOW_PRIVATE / test-env escape hatches
10 * - resolvesToPrivate(): injectable resolver, best-effort on failure
11 * - mirrors wiring: validateUpstreamUrl() rejects private upstreams
12 *
13 * The guard default-allows private addresses when NODE_ENV=test, so the
14 * env-dependent suites set SSRF_ENFORCE_IN_TEST=1 up front and restore
15 * the original env afterwards. isPrivateAddress() itself is pure and
16 * needs no env handling.
17 */
18
19import { afterAll, beforeAll, describe, expect, it } from "bun:test";
20import {
21 assertPublicUrl,
22 isPrivateAddress,
23 parseIpv4,
24 resolvesToPrivate,
25 ssrfPrivateAllowed,
26} from "../lib/ssrf-guard";
27import { validateUpstreamUrl } from "../lib/mirrors";
28
29// ---------------------------------------------------------------------------
30// Env bookkeeping — enforce blocking for this file, restore on exit.
31// ---------------------------------------------------------------------------
32
33const ORIGINAL_ENV = {
34 enforce: process.env.SSRF_ENFORCE_IN_TEST,
35 allow: process.env.SSRF_ALLOW_PRIVATE,
36};
37
38beforeAll(() => {
39 process.env.SSRF_ENFORCE_IN_TEST = "1";
40 delete process.env.SSRF_ALLOW_PRIVATE;
41});
42
43afterAll(() => {
44 if (ORIGINAL_ENV.enforce === undefined) {
45 delete process.env.SSRF_ENFORCE_IN_TEST;
46 } else {
47 process.env.SSRF_ENFORCE_IN_TEST = ORIGINAL_ENV.enforce;
48 }
49 if (ORIGINAL_ENV.allow === undefined) {
50 delete process.env.SSRF_ALLOW_PRIVATE;
51 } else {
52 process.env.SSRF_ALLOW_PRIVATE = ORIGINAL_ENV.allow;
53 }
54});
55
56// ---------------------------------------------------------------------------
57// isPrivateAddress — hostname conventions
58// ---------------------------------------------------------------------------
59
60describe("ssrf-guard — isPrivateAddress hostnames", () => {
61 it("blocks localhost and *.localhost", () => {
62 expect(isPrivateAddress("localhost")).toBe(true);
63 expect(isPrivateAddress("LOCALHOST")).toBe(true);
64 expect(isPrivateAddress("foo.localhost")).toBe(true);
65 expect(isPrivateAddress("a.b.localhost")).toBe(true);
66 expect(isPrivateAddress("localhost.")).toBe(true); // trailing-dot FQDN
67 });
68
69 it("blocks .local and .internal TLDs", () => {
70 expect(isPrivateAddress("printer.local")).toBe(true);
71 expect(isPrivateAddress("db.prod.internal")).toBe(true);
72 expect(isPrivateAddress("metadata.google.internal")).toBe(true);
73 });
74
75 it("does not block lookalike hostnames", () => {
76 expect(isPrivateAddress("localhost.example.com")).toBe(false);
77 expect(isPrivateAddress("notlocalhost")).toBe(false);
78 expect(isPrivateAddress("internal.example.com")).toBe(false);
79 expect(isPrivateAddress("locality.example.org")).toBe(false);
80 });
81
82 it("blocks the empty host", () => {
83 expect(isPrivateAddress("")).toBe(true);
84 });
85});
86
87// ---------------------------------------------------------------------------
88// isPrivateAddress — IPv4 ranges
89// ---------------------------------------------------------------------------
90
91describe("ssrf-guard — isPrivateAddress IPv4 ranges", () => {
92 it("blocks 127.0.0.0/8 loopback", () => {
93 expect(isPrivateAddress("127.0.0.1")).toBe(true);
94 expect(isPrivateAddress("127.255.255.255")).toBe(true);
95 expect(isPrivateAddress("127.1.2.3")).toBe(true);
96 });
97
98 it("blocks 10.0.0.0/8", () => {
99 expect(isPrivateAddress("10.0.0.1")).toBe(true);
100 expect(isPrivateAddress("10.255.255.255")).toBe(true);
101 });
102
103 it("blocks 172.16.0.0/12 (and only that slice of 172/8)", () => {
104 expect(isPrivateAddress("172.16.0.1")).toBe(true);
105 expect(isPrivateAddress("172.31.255.255")).toBe(true);
106 expect(isPrivateAddress("172.15.0.1")).toBe(false);
107 expect(isPrivateAddress("172.32.0.1")).toBe(false);
108 });
109
110 it("blocks 192.168.0.0/16", () => {
111 expect(isPrivateAddress("192.168.0.1")).toBe(true);
112 expect(isPrivateAddress("192.168.255.255")).toBe(true);
113 expect(isPrivateAddress("192.169.0.1")).toBe(false);
114 });
115
116 it("blocks 169.254.0.0/16 link-local (cloud metadata)", () => {
117 expect(isPrivateAddress("169.254.169.254")).toBe(true);
118 expect(isPrivateAddress("169.254.0.1")).toBe(true);
119 expect(isPrivateAddress("169.253.0.1")).toBe(false);
120 });
121
122 it("blocks 100.64.0.0/10 CGNAT (and only that slice of 100/8)", () => {
123 expect(isPrivateAddress("100.64.0.1")).toBe(true);
124 expect(isPrivateAddress("100.127.255.255")).toBe(true);
125 expect(isPrivateAddress("100.63.255.255")).toBe(false);
126 expect(isPrivateAddress("100.128.0.1")).toBe(false);
127 });
128
129 it("blocks 0.0.0.0/8 and the broadcast address", () => {
130 expect(isPrivateAddress("0.0.0.0")).toBe(true);
131 expect(isPrivateAddress("0.1.2.3")).toBe(true);
132 expect(isPrivateAddress("255.255.255.255")).toBe(true);
133 });
134
135 it("allows public IPv4", () => {
136 expect(isPrivateAddress("8.8.8.8")).toBe(false);
137 expect(isPrivateAddress("1.1.1.1")).toBe(false);
138 expect(isPrivateAddress("93.184.216.34")).toBe(false);
139 expect(isPrivateAddress("255.255.255.254")).toBe(false);
140 });
141});
142
143// ---------------------------------------------------------------------------
144// isPrivateAddress — integer/hex/octal IPv4 encodings
145// ---------------------------------------------------------------------------
146
147describe("ssrf-guard — IPv4 integer encodings", () => {
148 it("parses single-integer forms (parseIpv4)", () => {
149 expect(parseIpv4("2130706433")).toBe(0x7f000001); // 127.0.0.1
150 expect(parseIpv4("0x7f000001")).toBe(0x7f000001);
151 expect(parseIpv4("017700000001")).toBe(0x7f000001); // octal
152 expect(parseIpv4("example.com")).toBeNull();
153 expect(parseIpv4("4294967296")).toBeNull(); // > 0xffffffff
154 });
155
156 it("blocks decimal-integer loopback (http://2130706433/)", () => {
157 expect(isPrivateAddress("2130706433")).toBe(true);
158 });
159
160 it("blocks hex-integer loopback (0x7f000001)", () => {
161 expect(isPrivateAddress("0x7f000001")).toBe(true);
162 });
163
164 it("blocks octal-integer loopback (017700000001)", () => {
165 expect(isPrivateAddress("017700000001")).toBe(true);
166 });
167
168 it("blocks mixed-radix dotted forms", () => {
169 expect(isPrivateAddress("0177.0.0.1")).toBe(true); // octal octet
170 expect(isPrivateAddress("0xa.0.0.1")).toBe(true); // hex octet → 10.0.0.1
171 expect(isPrivateAddress("0x7f.1")).toBe(true); // 2-part → 127.0.0.1
172 expect(isPrivateAddress("127.1")).toBe(true); // inet_aton short form
173 expect(isPrivateAddress("169.254.43518")).toBe(true); // 3-part link-local
174 });
175
176 it("allows public integer encodings", () => {
177 expect(isPrivateAddress("134744072")).toBe(false); // 8.8.8.8
178 expect(isPrivateAddress("0x08080808")).toBe(false);
179 });
180});
181
182// ---------------------------------------------------------------------------
183// isPrivateAddress — IPv6 forms
184// ---------------------------------------------------------------------------
185
186describe("ssrf-guard — isPrivateAddress IPv6", () => {
187 it("blocks ::1 loopback and :: unspecified", () => {
188 expect(isPrivateAddress("::1")).toBe(true);
189 expect(isPrivateAddress("::")).toBe(true);
190 expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
191 });
192
193 it("handles the URL bracket form", () => {
194 expect(isPrivateAddress("[::1]")).toBe(true);
195 expect(isPrivateAddress("[fe80::1]")).toBe(true);
196 expect(isPrivateAddress("[2606:4700::1111]")).toBe(false);
197 });
198
199 it("blocks fc00::/7 unique-local", () => {
200 expect(isPrivateAddress("fc00::1")).toBe(true);
201 expect(isPrivateAddress("fd12:3456:789a::1")).toBe(true);
202 expect(isPrivateAddress("fdff:ffff::1")).toBe(true);
203 expect(isPrivateAddress("fbff::1")).toBe(false); // just below fc00::/7
204 expect(isPrivateAddress("fe00::1")).toBe(false); // just above
205 });
206
207 it("blocks fe80::/10 link-local (incl. zone ids)", () => {
208 expect(isPrivateAddress("fe80::1")).toBe(true);
209 expect(isPrivateAddress("febf::1")).toBe(true); // top of /10
210 expect(isPrivateAddress("fe80::1%eth0")).toBe(true);
211 expect(isPrivateAddress("fec0::1")).toBe(false); // above the /10
212 });
213
214 it("blocks IPv4-mapped private addresses", () => {
215 expect(isPrivateAddress("::ffff:10.0.0.1")).toBe(true);
216 expect(isPrivateAddress("::ffff:127.0.0.1")).toBe(true);
217 expect(isPrivateAddress("::ffff:192.168.1.1")).toBe(true);
218 expect(isPrivateAddress("::ffff:169.254.169.254")).toBe(true);
219 expect(isPrivateAddress("::ffff:7f00:1")).toBe(true); // hex-word mapped 127.0.0.1
220 });
221
222 it("allows IPv4-mapped public addresses and public IPv6", () => {
223 expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false);
224 expect(isPrivateAddress("2606:4700::1111")).toBe(false);
225 expect(isPrivateAddress("2001:4860:4860::8888")).toBe(false);
226 });
227});
228
229// ---------------------------------------------------------------------------
230// assertPublicUrl
231// ---------------------------------------------------------------------------
232
233describe("ssrf-guard — assertPublicUrl", () => {
234 it("accepts public http/https URLs", () => {
235 const r1 = assertPublicUrl("https://example.com/webhook");
236 expect(r1.ok).toBe(true);
237 if (r1.ok) expect(r1.url.hostname).toBe("example.com");
238 expect(assertPublicUrl("http://api.example.org:8080/x").ok).toBe(true);
239 expect(assertPublicUrl("https://user:pw@example.com/x").ok).toBe(true); // creds allowed
240 });
241
242 it("rejects non-http(s) schemes by default", () => {
243 for (const raw of [
244 "ftp://example.com/x",
245 "file:///etc/passwd",
246 "gopher://example.com/x",
247 "git://example.com/x.git",
248 ]) {
249 const r = assertPublicUrl(raw);
250 expect(r.ok).toBe(false);
251 if (!r.ok) expect(r.reason).toContain("scheme");
252 }
253 });
254
255 it("accepts widened schemes via opts.schemes", () => {
256 const r = assertPublicUrl("git://kernel.org/linux.git", {
257 schemes: ["http:", "https:", "git:"],
258 });
259 expect(r.ok).toBe(true);
260 });
261
262 it("rejects unparseable URLs", () => {
263 const r = assertPublicUrl("not a url");
264 expect(r.ok).toBe(false);
265 if (!r.ok) expect(r.reason).toContain("invalid");
266 });
267
268 it("rejects private hosts in every encoding", () => {
269 for (const raw of [
270 "http://localhost:3000/hook",
271 "http://127.0.0.1/hook",
272 "http://10.1.2.3/hook",
273 "http://172.16.0.1/hook",
274 "http://192.168.1.1/hook",
275 "http://169.254.169.254/latest/meta-data/",
276 "http://100.64.0.1/hook",
277 "http://0.0.0.0/hook",
278 "http://2130706433/hook", // decimal 127.0.0.1 (URL normalizes it)
279 "http://0x7f000001/hook", // hex 127.0.0.1
280 "http://[::1]/hook",
281 "http://[fd00::1]/hook",
282 "http://[fe80::1]/hook",
283 "http://[::ffff:10.0.0.1]/hook",
284 "http://internal-db.local/hook",
285 "http://svc.cluster.internal/hook",
286 ]) {
287 const r = assertPublicUrl(raw);
288 expect(r.ok).toBe(false);
289 if (!r.ok) expect(r.reason).toContain("private");
290 }
291 });
292
293 it("opts.allowPrivate=true bypasses the private-host check", () => {
294 expect(
295 assertPublicUrl("http://127.0.0.1/hook", { allowPrivate: true }).ok
296 ).toBe(true);
297 // ...but not the scheme check.
298 expect(
299 assertPublicUrl("file:///etc/passwd", { allowPrivate: true }).ok
300 ).toBe(false);
301 });
302});
303
304// ---------------------------------------------------------------------------
305// Escape hatches — env read at call time
306// ---------------------------------------------------------------------------
307
308describe("ssrf-guard — env escape hatches", () => {
309 it("SSRF_ALLOW_PRIVATE=1 allows private hosts (read at call time)", () => {
310 expect(assertPublicUrl("http://127.0.0.1/hook").ok).toBe(false);
311 process.env.SSRF_ALLOW_PRIVATE = "1";
312 try {
313 expect(ssrfPrivateAllowed()).toBe(true);
314 expect(assertPublicUrl("http://127.0.0.1/hook").ok).toBe(true);
315 expect(assertPublicUrl("http://192.168.1.1/hook").ok).toBe(true);
316 } finally {
317 delete process.env.SSRF_ALLOW_PRIVATE;
318 }
319 expect(assertPublicUrl("http://127.0.0.1/hook").ok).toBe(false);
320 });
321
322 it("test env default-allows unless SSRF_ENFORCE_IN_TEST=1", () => {
323 // This file sets SSRF_ENFORCE_IN_TEST=1 in beforeAll; lifting it while
324 // NODE_ENV=test (bun test sets it) must default to allow, so existing
325 // suites that POST to localhost keep passing untouched.
326 delete process.env.SSRF_ENFORCE_IN_TEST;
327 try {
328 expect(ssrfPrivateAllowed()).toBe(true);
329 expect(assertPublicUrl("http://localhost:1234/hook").ok).toBe(true);
330 } finally {
331 process.env.SSRF_ENFORCE_IN_TEST = "1";
332 }
333 expect(ssrfPrivateAllowed()).toBe(false);
334 expect(assertPublicUrl("http://localhost:1234/hook").ok).toBe(false);
335 });
336});
337
338// ---------------------------------------------------------------------------
339// resolvesToPrivate — injectable DNS layer
340// ---------------------------------------------------------------------------
341
342describe("ssrf-guard — resolvesToPrivate", () => {
343 it("short-circuits on private literals without resolving", async () => {
344 let called = false;
345 const resolver = async () => {
346 called = true;
347 return [{ address: "8.8.8.8" }];
348 };
349 expect(await resolvesToPrivate("127.0.0.1", resolver)).toBe(true);
350 expect(called).toBe(false);
351 });
352
353 it("flags hostnames that resolve to private addresses", async () => {
354 const resolver = async () => [{ address: "10.0.0.5" }];
355 expect(await resolvesToPrivate("evil.example.com", resolver)).toBe(true);
356 });
357
358 it("flags when any of several records is private", async () => {
359 const resolver = async () => [
360 { address: "93.184.216.34" },
361 { address: "169.254.169.254" },
362 ];
363 expect(await resolvesToPrivate("dual.example.com", resolver)).toBe(true);
364 });
365
366 it("passes hostnames that resolve publicly", async () => {
367 const resolver = async () => [{ address: "93.184.216.34" }];
368 expect(await resolvesToPrivate("example.com", resolver)).toBe(false);
369 });
370
371 it("is best-effort: resolution failure does not block", async () => {
372 const resolver = async () => {
373 throw new Error("ENOTFOUND");
374 };
375 expect(await resolvesToPrivate("nx.example.com", resolver)).toBe(false);
376 });
377});
378
379// ---------------------------------------------------------------------------
380// Wiring — mirrors.validateUpstreamUrl uses the guard
381// ---------------------------------------------------------------------------
382
383describe("ssrf-guard — mirrors wiring", () => {
384 it("validateUpstreamUrl rejects private upstreams when enforced", () => {
385 for (const raw of [
386 "http://127.0.0.1/x.git",
387 "http://localhost:3000/x.git",
388 "https://169.254.169.254/x.git",
389 "git://10.0.0.1/x.git",
390 "http://2130706433/x.git",
391 ]) {
392 const r = validateUpstreamUrl(raw);
393 expect(r.ok).toBe(false);
394 expect(r.error).toContain("SSRF");
395 }
396 });
397
398 it("validateUpstreamUrl still accepts public upstreams", () => {
399 expect(validateUpstreamUrl("https://github.com/foo/bar.git").ok).toBe(true);
400 expect(validateUpstreamUrl("git://kernel.org/linux.git").ok).toBe(true);
401 });
402});