diff --git a/src/local-guard.test.ts b/src/local-guard.test.ts index 57d684e5..83dc0d2c 100644 --- a/src/local-guard.test.ts +++ b/src/local-guard.test.ts @@ -41,13 +41,29 @@ describe("isBlockedSsrfHost", () => { "2002:7f00:1::", "64:ff9b::7f00:1", "fd00::1", + "fe80::1", ]) { expect(isBlockedSsrfHost(h), h).toBe(true); } }); + it("blocks the whole fe80::/10 link-local range, not only the fe80:: prefix", () => { + for (const h of ["fe81::1", "fe9f::1", "fea0::1", "febf::1", "[febf::1]", "FE81::ABCD"]) { + expect(isBlockedSsrfHost(h), h).toBe(true); + } + }); + it("allows public hosts", () => { - for (const h of ["example.com", "8.8.8.8", "fda.gov", "blockrun.ai"]) { + for (const h of [ + "example.com", + "8.8.8.8", + "fda.gov", + "blockrun.ai", + "fe7f::1", + "fe8::1", + "fec0::1", + "2001:4860:4860::8888", + ]) { expect(isBlockedSsrfHost(h), h).toBe(false); } }); @@ -100,6 +116,15 @@ describe("resolvePublicAddresses", () => { ]); }); + it("refuses a link-local IPv6 literal outside the fe80:: prefix", async () => { + const resolve = async () => { + throw new Error("resolver called"); + }; + await expect(resolvePublicAddresses("[fe81::1]", resolve)).rejects.toThrow( + /private\/loopback\/metadata/, + ); + }); + it("refuses a name that resolves to nothing", async () => { await expect(resolvePublicAddresses("void.example", dnsOf({}))).rejects.toThrow( /could not resolve/, diff --git a/src/local-guard.ts b/src/local-guard.ts index 77e9b424..dc2df682 100644 --- a/src/local-guard.ts +++ b/src/local-guard.ts @@ -89,6 +89,18 @@ function v4FromHex(hi: number, lo: number): string { return `${(hi >>> 8) & 255}.${hi & 255}.${(lo >>> 8) & 255}.${lo & 255}`; } +/** + * fe80::/10 (RFC 4291). Link-local is fe80:: through febf::, not only the + * fe80:: prefix. The first hextet carries those 10 bits, so fe81::1 is + * link-local while fe8::1 (hextet 0x0fe8) is not. + */ +function isIpv6LinkLocal(address: string): boolean { + const first = address.split(":")[0]; + if (!/^[0-9a-f]{1,4}$/.test(first)) return false; + const value = parseInt(first, 16); + return value >= 0xfe80 && value <= 0xfebf; +} + /** * True for loopback, private, link-local, CGNAT and cloud-metadata hosts, * including the IPv6 forms that embed an IPv4 address. Literal-host based: @@ -104,7 +116,7 @@ export function isBlockedSsrfHost(hostname: string): boolean { if (h.includes(":")) { if (h === "::1" || h === "::") return true; - if (h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) return true; + if (isIpv6LinkLocal(h) || h.startsWith("fc") || h.startsWith("fd")) return true; // 6to4: 2002:WWXX:YYZZ::/16 embeds W.X.Y.Z. const sixToFour = h.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/); if (sixToFour) {