From d05c8eb9121047db001feee7098a63198044ac74 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 10:25:25 +0100 Subject: [PATCH] refactor: unify SSRF hostname/ip precheck and add policy regression --- src/infra/net/ssrf.pinning.test.ts | 31 ++++++++++++++++++++++++++++++ src/infra/net/ssrf.ts | 16 +++++++-------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index f04c55b8ab..5196514566 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -4,6 +4,7 @@ import { type LookupFn, resolvePinnedHostname, resolvePinnedHostnameWithPolicy, + SsrFBlockedError, } from "./ssrf.js"; describe("ssrf pinning", () => { @@ -107,4 +108,34 @@ describe("ssrf pinning", () => { }), ).rejects.toThrow(/allowlist/i); }); + + it("blocks ISATAP embedded private IPv4 before DNS lookup", async () => { + const lookup = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + ]) as unknown as LookupFn; + + await expect( + resolvePinnedHostnameWithPolicy("2001:db8:1234::5efe:127.0.0.1", { + lookupFn: lookup, + }), + ).rejects.toThrow(SsrFBlockedError); + expect(lookup).not.toHaveBeenCalled(); + }); + + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { + const lookup = vi.fn(async () => [ + { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, + ]) as unknown as LookupFn; + + await expect( + resolvePinnedHostnameWithPolicy("2001:db8:1234::5efe:127.0.0.1", { + lookupFn: lookup, + policy: { allowPrivateNetwork: true }, + }), + ).resolves.toMatchObject({ + hostname: "2001:db8:1234::5efe:127.0.0.1", + addresses: ["2001:db8:1234::5efe:127.0.0.1"], + }); + expect(lookup).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 33207d108f..90ed62cf12 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -316,6 +316,10 @@ export function isBlockedHostname(hostname: string): boolean { if (!normalized) { return false; } + return isBlockedHostnameNormalized(normalized); +} + +function isBlockedHostnameNormalized(normalized: string): boolean { if (BLOCKED_HOSTNAMES.has(normalized)) { return true; } @@ -331,7 +335,7 @@ export function isBlockedHostnameOrIp(hostname: string): boolean { if (!normalized) { return false; } - return isBlockedHostname(normalized) || isPrivateIpAddress(normalized); + return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized); } export function createPinnedLookup(params: { @@ -415,14 +419,8 @@ export async function resolvePinnedHostnameWithPolicy( throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`); } - if (!allowPrivateNetwork && !isExplicitAllowed) { - if (isBlockedHostname(normalized)) { - throw new SsrFBlockedError(`Blocked hostname: ${hostname}`); - } - - if (isPrivateIpAddress(normalized)) { - throw new SsrFBlockedError("Blocked: private/internal IP address"); - } + if (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) { + throw new SsrFBlockedError("Blocked hostname or private/internal IP address"); } const lookupFn = params.lookupFn ?? dnsLookup;