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.
| bc519ae | 1 | /** |
| 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 | ||
| 19 | import { afterAll, beforeAll, describe, expect, it } from "bun:test"; | |
| 20 | import { | |
| 21 | assertPublicUrl, | |
| 22 | isPrivateAddress, | |
| 23 | parseIpv4, | |
| 24 | resolvesToPrivate, | |
| 25 | ssrfPrivateAllowed, | |
| 26 | } from "../lib/ssrf-guard"; | |
| 27 | import { validateUpstreamUrl } from "../lib/mirrors"; | |
| 28 | ||
| 29 | // --------------------------------------------------------------------------- | |
| 30 | // Env bookkeeping — enforce blocking for this file, restore on exit. | |
| 31 | // --------------------------------------------------------------------------- | |
| 32 | ||
| 33 | const ORIGINAL_ENV = { | |
| 34 | enforce: process.env.SSRF_ENFORCE_IN_TEST, | |
| 35 | allow: process.env.SSRF_ALLOW_PRIVATE, | |
| 36 | }; | |
| 37 | ||
| 38 | beforeAll(() => { | |
| 39 | process.env.SSRF_ENFORCE_IN_TEST = "1"; | |
| 40 | delete process.env.SSRF_ALLOW_PRIVATE; | |
| 41 | }); | |
| 42 | ||
| 43 | afterAll(() => { | |
| 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 | ||
| 60 | describe("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 | ||
| 91 | describe("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 | ||
| 147 | describe("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 | ||
| 186 | describe("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 | ||
| 233 | describe("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 | ||
| 308 | describe("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 | ||
| 342 | describe("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 | ||
| 383 | describe("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 | }); |