From 5d017fd2b1b067abe6bd5eee55b004f795a3f4c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:00:01 +0200 Subject: [PATCH] fix: block loopback, CGNAT and IPv6 transition ranges in the SSRF guard The URL guard behind fetch-ical, OAuth discovery, branding and the server-side JMAP fetches blocked only the single address 127.0.0.1 plus the RFC 1918, link-local and ULA subnets. Any logged-in user could make the server fetch 127.0.0.2, 100.64.0.0/10, 198.18.0.0/15, multicast, reserved space or a NAT64/6to4/Teredo address that wraps an internal IPv4 address. The telemetry guard already rejected all of these (GHSA-m7j8), so its range check moves to `lib/security/ip-ranges.ts` and both guards use it. `guardedLookup` inherits the wider list, so the connect-time rebinding check covers the new ranges too. --- lib/__tests__/url-guard.test.ts | 48 ++++++++++++++ lib/security/ip-ranges.ts | 110 ++++++++++++++++++++++++++++++++ lib/security/url-guard.ts | 24 ++----- lib/telemetry/endpoint-guard.ts | 100 +---------------------------- 4 files changed, 166 insertions(+), 116 deletions(-) create mode 100644 lib/security/ip-ranges.ts diff --git a/lib/__tests__/url-guard.test.ts b/lib/__tests__/url-guard.test.ts index 762263820..613c302be 100644 --- a/lib/__tests__/url-guard.test.ts +++ b/lib/__tests__/url-guard.test.ts @@ -97,6 +97,45 @@ describe('isPublicHttpUrl', () => { expect(lookup).not.toHaveBeenCalled(); }); + it.each([ + 'http://127.0.0.2/', + 'http://127.255.255.254/', + 'http://100.64.0.1/', + 'http://100.100.100.200/', + 'http://198.18.0.1/', + 'http://240.0.0.1/', + 'http://224.0.0.1/', + 'http://255.255.255.255/', + 'http://0.1.2.3/', + 'http://[::ffff:127.0.0.2]/', + 'http://[64:ff9b::7f00:1]/', + 'http://[64:ff9b::a9fe:a9fe]/', + 'http://[64:ff9b:1::1]/', + 'http://[2002:7f00:1::]/', + 'http://[2002:a9fe:a9fe::]/', + 'http://[2001:0:4136:e378:8000:63bf:80ff:fffe]/', + 'http://[ff02::1]/', + ])('rejects the special-purpose literal %s', async (url) => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl(url)).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it.each(['127.0.0.2', '100.64.0.1', '198.18.0.1', '64:ff9b::7f00:1', '2002:7f00:1::'])( + 'rejects a hostname resolving to %s', + async (address) => { + lookup.mockResolvedValue([{ address, family: address.includes(':') ? 6 : 4 }]); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://rebind.example.com/')).toBe(false); + }, + ); + + it('accepts public addresses embedded in NAT64 / 6to4 forms', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('http://[64:ff9b::5db8:d822]/')).toBe(true); + expect(await isPublicHttpUrl('http://[2002:5db8:d822::1]/')).toBe(true); + }); + it('rejects when DNS resolves to a private address (rebinding)', async () => { lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); const isPublicHttpUrl = await load(); @@ -186,6 +225,15 @@ describe('guardedLookup', () => { expect(result.err).toMatchObject({ code: 'EBLOCKED', address: '169.254.169.254' }); }); + it.each(['127.0.0.2', '100.64.0.1', '64:ff9b::7f00:1'])( + 'refuses a public name that resolves to %s', + async (address) => { + lookup.mockResolvedValue([{ address, family: address.includes(':') ? 6 : 4 }]); + const result = await run('rebind.attacker.example', { all: true }); + expect(result.err).toMatchObject({ code: 'EBLOCKED', address }); + }, + ); + it('rebinding: a name that was public on the first answer is refused on the second', async () => { lookup .mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]) diff --git a/lib/security/ip-ranges.ts b/lib/security/ip-ranges.ts new file mode 100644 index 000000000..282938de1 --- /dev/null +++ b/lib/security/ip-ranges.ts @@ -0,0 +1,110 @@ +import { isIP } from 'node:net'; + +// The one definition of "not a public unicast address" for every server-side +// guard (url-guard for caller-supplied URLs, telemetry for the admin-set +// collector). Two copies drifted once: url-guard blocked only 127.0.0.1 while +// telemetry already rejected all of 127/8, CGNAT and the IPv6 transition +// prefixes. + +const PRIVATE_V4: RegExp[] = [ + /^0\./, // 0.0.0.0/8 + /^10\./, // 10.0.0.0/8 + /^127\./, // loopback + /^169\.254\./, // link-local + cloud metadata + /^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^192\.0\.0\./, // IETF reserved + /^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15 + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT + /^22[4-9]\./, // 224.0.0.0/4 multicast + /^23\d\./, + /^2[4-5]\d\./, // 240.0.0.0/4 reserved + broadcast +]; + +function isPrivateV4(ip: string): boolean { + return PRIVATE_V4.some((re) => re.test(ip)); +} + +// Expand an IPv6 literal into its eight 16-bit groups. Accepts `::` +// compression, a trailing embedded dotted quad (`::ffff:127.0.0.1`) and a +// zone id suffix. Returns null when the string isn't a well-formed address. +// +// We parse structurally rather than regex-matching the textual form because +// the same address has several spellings: the WHATWG URL parser rewrites +// `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, while dns.lookup returns the +// dotted form. A textual check that only knew one spelling let the other +// straight through (GHSA-m7j8-f5q4-vj7x). +export function parseV6Groups(ip: string): number[] | null { + let s = ip.toLowerCase(); + const zone = s.indexOf('%'); + if (zone >= 0) s = s.slice(0, zone); + + const v4 = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const o = v4.slice(2, 6).map(Number); + if (o.some((n) => n > 255)) return null; + s = `${v4[1]}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`; + } + + const halves = s.split('::'); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(':') : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; + const missing = 8 - head.length - tail.length; + if (missing < 0 || (halves.length === 1 && missing !== 0)) return null; + if (halves.length === 2 && missing === 0) return null; + + const groups = [...head, ...new Array(missing).fill('0'), ...tail] + .map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN)); + if (groups.some(Number.isNaN)) return null; + return groups; +} + +function v4FromGroups(hi: number, lo: number): string { + return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; +} + +function isPrivateV6(ip: string): boolean { + const g = parseV6Groups(ip); + // Fail closed: isIP() said this is IPv6 but we can't make sense of it. + if (!g) return true; + + const leadingZero = (n: number) => g.slice(0, n).every((x) => x === 0); + + if (leadingZero(7) && (g[7] === 0 || g[7] === 1)) return true; // :: and ::1 + if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast + if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA + if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo + if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 local NAT64 + + // Forms that embed an IPv4 address: judge them by the embedded address so + // an IPv6-only host behind DNS64 can still reach a public server. + if (leadingZero(5) && g[5] === 0xffff) { // ::ffff:0:0/96 IPv4-mapped + return isPrivateV4(v4FromGroups(g[6], g[7])); + } + if (leadingZero(6)) { // ::/96 IPv4-compatible (deprecated) + return isPrivateV4(v4FromGroups(g[6], g[7])); + } + if (g[0] === 0x64 && g[1] === 0xff9b && + g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { // 64:ff9b::/96 NAT64 + return isPrivateV4(v4FromGroups(g[6], g[7])); + } + if (g[0] === 0x2002) { // 2002::/16 6to4 + return isPrivateV4(v4FromGroups(g[1], g[2])); + } + return false; +} + +/** + * True for any address a server-side fetch must not reach: loopback, + * RFC 1918, link-local, CGNAT, benchmarking, reserved, multicast, ULA, and + * IPv6 transition forms whose embedded IPv4 address is one of those. + * Returns false for anything that is not an IP literal. + */ +export function isPrivateAddress(ip: string): boolean { + const family = isIP(ip); + if (family === 4) return isPrivateV4(ip); + if (family === 6) return isPrivateV6(ip); + return false; +} diff --git a/lib/security/url-guard.ts b/lib/security/url-guard.ts index 6bdbd7614..6d9b865ee 100644 --- a/lib/security/url-guard.ts +++ b/lib/security/url-guard.ts @@ -1,6 +1,6 @@ import { lookup } from 'node:dns/promises'; import type { LookupAddress, LookupOptions } from 'node:dns'; -import { BlockList, isIP, type LookupFunction } from 'node:net'; +import { isIP, type LookupFunction } from 'node:net'; import { Agent, fetch as undiciFetch, @@ -8,18 +8,7 @@ import { type RequestInit as UndiciRequestInit, type Response as UndiciResponse, } from 'undici'; - -const blockedAddressRanges = new BlockList(); -blockedAddressRanges.addAddress('0.0.0.0'); -blockedAddressRanges.addAddress('127.0.0.1'); -blockedAddressRanges.addSubnet('10.0.0.0', 8); -blockedAddressRanges.addSubnet('172.16.0.0', 12); -blockedAddressRanges.addSubnet('192.168.0.0', 16); -blockedAddressRanges.addSubnet('169.254.0.0', 16); -blockedAddressRanges.addAddress('::', 'ipv6'); -blockedAddressRanges.addAddress('::1', 'ipv6'); -blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6'); -blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6'); +import { isPrivateAddress } from './ip-ranges'; const BLOCKED_HOSTNAMES = new Set(['localhost']); const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain']; @@ -29,11 +18,7 @@ function normalizeHostname(hostname: string): string { } function isBlockedIpAddress(hostname: string): boolean { - const normalized = normalizeHostname(hostname); - const family = isIP(normalized); - if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4'); - if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6'); - return false; + return isPrivateAddress(normalizeHostname(hostname)); } function isBlockedHostname(hostname: string): boolean { @@ -67,7 +52,8 @@ function parseAllowedUrl(urlString: string): URL | null { /** * Returns true only when the URL targets a public host reachable over http(s). - * Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname + * Rejects every non-public range in {@link isPrivateAddress} (loopback, + * RFC 1918, link-local, CGNAT, multicast, IPv6 transition forms), special hostname * suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials, * and any hostname whose DNS resolves to a blocked address. * diff --git a/lib/telemetry/endpoint-guard.ts b/lib/telemetry/endpoint-guard.ts index a17d9daf3..43d425a83 100644 --- a/lib/telemetry/endpoint-guard.ts +++ b/lib/telemetry/endpoint-guard.ts @@ -1,5 +1,8 @@ import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; +import { isPrivateAddress } from '@/lib/security/ip-ranges'; + +export { isPrivateAddress, parseV6Groups } from '@/lib/security/ip-ranges'; // Block telemetry endpoints from pointing at internal/loopback addresses. // Required because the admin UI lets an authenticated admin set an arbitrary @@ -9,103 +12,6 @@ import { isIP } from 'node:net'; // Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local // dev where the collector is on the loopback. -const PRIVATE_V4: RegExp[] = [ - /^0\./, // 0.0.0.0/8 - /^10\./, // 10.0.0.0/8 - /^127\./, // loopback - /^169\.254\./, // link-local + cloud metadata - /^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12 - /^192\.168\./, // 192.168.0.0/16 - /^192\.0\.0\./, // IETF reserved - /^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15 - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT - /^22[4-9]\./, // 224.0.0.0/4 multicast - /^23\d\./, - /^2[4-5]\d\./, // 240.0.0.0/4 reserved -]; - -function isPrivateV4(ip: string): boolean { - return PRIVATE_V4.some((re) => re.test(ip)); -} - -// Expand an IPv6 literal into its eight 16-bit groups. Accepts `::` -// compression, a trailing embedded dotted quad (`::ffff:127.0.0.1`) and a -// zone id suffix. Returns null when the string isn't a well-formed address. -// -// We parse structurally rather than regex-matching the textual form because -// the same address has several spellings: the WHATWG URL parser rewrites -// `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, while dns.lookup returns the -// dotted form. A textual check that only knew one spelling let the other -// straight through (GHSA-m7j8-f5q4-vj7x). -export function parseV6Groups(ip: string): number[] | null { - let s = ip.toLowerCase(); - const zone = s.indexOf('%'); - if (zone >= 0) s = s.slice(0, zone); - - const v4 = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (v4) { - const o = v4.slice(2, 6).map(Number); - if (o.some((n) => n > 255)) return null; - s = `${v4[1]}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`; - } - - const halves = s.split('::'); - if (halves.length > 2) return null; - const head = halves[0] ? halves[0].split(':') : []; - const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; - const missing = 8 - head.length - tail.length; - if (missing < 0 || (halves.length === 1 && missing !== 0)) return null; - if (halves.length === 2 && missing === 0) return null; - - const groups = [...head, ...new Array(missing).fill('0'), ...tail] - .map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN)); - if (groups.some(Number.isNaN)) return null; - return groups; -} - -function v4FromGroups(hi: number, lo: number): string { - return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; -} - -function isPrivateV6(ip: string): boolean { - const g = parseV6Groups(ip); - // Fail closed: isIP() said this is IPv6 but we can't make sense of it. - if (!g) return true; - - const leadingZero = (n: number) => g.slice(0, n).every((x) => x === 0); - - if (leadingZero(7) && (g[7] === 0 || g[7] === 1)) return true; // :: and ::1 - if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast - if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local - if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA - if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo - if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 local NAT64 - - // Forms that embed an IPv4 address: judge them by the embedded address so - // an IPv6-only host behind DNS64 can still reach a public collector. - if (leadingZero(5) && g[5] === 0xffff) { // ::ffff:0:0/96 IPv4-mapped - return isPrivateV4(v4FromGroups(g[6], g[7])); - } - if (leadingZero(6)) { // ::/96 IPv4-compatible (deprecated) - return isPrivateV4(v4FromGroups(g[6], g[7])); - } - if (g[0] === 0x64 && g[1] === 0xff9b && - g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { // 64:ff9b::/96 NAT64 - return isPrivateV4(v4FromGroups(g[6], g[7])); - } - if (g[0] === 0x2002) { // 2002::/16 6to4 - return isPrivateV4(v4FromGroups(g[1], g[2])); - } - return false; -} - -export function isPrivateAddress(ip: string): boolean { - const family = isIP(ip); - if (family === 4) return isPrivateV4(ip); - if (family === 6) return isPrivateV6(ip); - return false; -} - const BAD_HOSTS = new Set([ 'localhost', 'localhost.localdomain',