diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index a03afba325f..223695c1a53 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -18,6 +18,7 @@ describe("fetchWithSsrFGuard hardening", () => { it("blocks private and legacy loopback literals before fetch", async () => { const blockedUrls = [ "http://127.0.0.1:8080/internal", + "http://[ff02::1]/internal", "http://0177.0.0.1:8080/internal", "http://0x7f000001/internal", ]; diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index e823b35be31..2698bf3db9e 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { blockedIpv6MulticastLiterals } from "../../shared/net/ip-test-fixtures.js"; import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; @@ -38,9 +39,7 @@ const privateIpCases = [ "fe80::1%lo0", "fd00::1", "fec0::1", - "ff02::1", - "ff05::1:3", - "[ff02::1]", + ...blockedIpv6MulticastLiterals, "2001:db8:1234::5efe:127.0.0.1", "2001:db8:1234:1:200:5efe:7f00:1", ]; diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index b84469390c0..8ba29b38e2a 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -4,11 +4,11 @@ import { Agent, type Dispatcher } from "undici"; import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, + isBlockedSpecialUseIpv6Address, isCanonicalDottedDecimalIPv4, type Ipv4SpecialUseBlockOptions, isIpv4Address, isLegacyIpv4Literal, - isPrivateOrLoopbackIpAddress, parseCanonicalIpAddress, parseLooseIpAddress, } from "../../shared/net/ip.js"; @@ -120,7 +120,7 @@ export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolea if (isIpv4Address(strictIp)) { return isBlockedSpecialUseIpv4Address(strictIp, blockOptions); } - if (isPrivateOrLoopbackIpAddress(strictIp.toString())) { + if (isBlockedSpecialUseIpv6Address(strictIp)) { return true; } const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); diff --git a/src/shared/net/ip-test-fixtures.ts b/src/shared/net/ip-test-fixtures.ts new file mode 100644 index 00000000000..d2fa9cd5436 --- /dev/null +++ b/src/shared/net/ip-test-fixtures.ts @@ -0,0 +1 @@ +export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const; diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index a8e4c9bd8e8..f89fb03f7ef 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js"; import { extractEmbeddedIpv4FromIpv6, isCanonicalDottedDecimalIPv4, @@ -47,8 +48,9 @@ describe("shared ip helpers", () => { it("treats blocked IPv6 classes as private/internal", () => { expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true); - expect(isPrivateOrLoopbackIpAddress("ff02::1")).toBe(true); - expect(isPrivateOrLoopbackIpAddress("[ff05::1:3]")).toBe(true); + for (const literal of blockedIpv6MulticastLiterals) { + expect(isPrivateOrLoopbackIpAddress(literal)).toBe(true); + } expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false); }); }); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index d1f1c0a9069..c386c687898 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -22,7 +22,7 @@ const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([ "carrierGradeNat", ]); -const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ +const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([ "unspecified", "loopback", "linkLocal", @@ -228,11 +228,15 @@ export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean { if (isIpv4Address(normalized)) { return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range()); } - if (PRIVATE_OR_LOOPBACK_IPV6_RANGES.has(normalized.range())) { + return isBlockedSpecialUseIpv6Address(normalized); +} + +export function isBlockedSpecialUseIpv6Address(address: ipaddr.IPv6): boolean { + if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(address.range())) { return true; } // ipaddr.js does not classify deprecated site-local fec0::/10 as private. - return (normalized.parts[0] & 0xffc0) === 0xfec0; + return (address.parts[0] & 0xffc0) === 0xfec0; } export function isRfc1918Ipv4Address(raw: string | undefined): boolean {