From 44dfbd23df453e51b71ef79a148c28c53e89168c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 15:41:32 +0100 Subject: [PATCH] fix(ssrf): centralize host/ip block checks --- src/gateway/server-cron.test.ts | 2 +- src/infra/net/ssrf.pinning.test.ts | 7 ++++-- src/infra/net/ssrf.ts | 35 +++++++++++++++++++++++------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index f34d4ad16..82a6d05f8 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -99,7 +99,7 @@ describe("buildGatewayCronService", () => { loadConfigMock.mockReturnValue(cfg); fetchWithSsrFGuardMock.mockRejectedValue( - new SsrFBlockedError("Blocked: private/internal IP address"), + new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"), ); const state = buildGatewayCronService({ diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 63902a62f..392577d23 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -49,8 +49,11 @@ describe("ssrf pinning", () => { ); }); - it("rejects private DNS results", async () => { - const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]) as unknown as LookupFn; + it.each([ + { name: "RFC1918 private address", address: "10.0.0.8" }, + { name: "RFC2544 benchmarking range", address: "198.18.0.1" }, + ])("rejects blocked DNS results: $name", async ({ address }) => { + const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn; await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 2301ac8a1..10f107541 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -316,6 +316,8 @@ const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [ { base: [240, 0, 0, 0], prefixLength: 4 }, ]; +// Keep this table as the single source of IPv4 non-global policy. +// Both plain IPv4 literals and IPv6-embedded IPv4 forms flow through it. const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr); function isBlockedIpv4SpecialUse(parts: number[]): boolean { @@ -430,6 +432,24 @@ export function isBlockedHostnameOrIp(hostname: string): boolean { return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized); } +const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address"; +const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address"; + +function assertAllowedHostOrIpOrThrow(hostnameOrIp: string): void { + if (isBlockedHostnameOrIp(hostnameOrIp)) { + throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE); + } +} + +function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void { + for (const entry of results) { + // Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift. + if (isBlockedHostnameOrIp(entry.address)) { + throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE); + } + } +} + export function createPinnedLookup(params: { hostname: string; addresses: string[]; @@ -506,13 +526,15 @@ export async function resolvePinnedHostnameWithPolicy( const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames); const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist); const isExplicitAllowed = allowedHostnames.has(normalized); + const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed; if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) { throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`); } - if (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) { - throw new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"); + if (!skipPrivateNetworkChecks) { + // Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects. + assertAllowedHostOrIpOrThrow(normalized); } const lookupFn = params.lookupFn ?? dnsLookup; @@ -521,12 +543,9 @@ export async function resolvePinnedHostnameWithPolicy( throw new Error(`Unable to resolve hostname: ${hostname}`); } - if (!allowPrivateNetwork && !isExplicitAllowed) { - for (const entry of results) { - if (isPrivateIpAddress(entry.address)) { - throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"); - } - } + if (!skipPrivateNetworkChecks) { + // Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets. + assertAllowedResolvedAddressesOrThrow(results); } const addresses = Array.from(new Set(results.map((entry) => entry.address)));