import type { AddressFamily, LookupAddress as AxiosLookupAddress } from 'axios' import { lookup as dnsLookup } from 'dns' import { isIP } from 'net' /** * SSRF guard for HTTP hooks. * * Blocks private, link-local, and other non-routable address ranges to prevent * project-configured HTTP hooks from reaching cloud metadata endpoints * (169.254.169.254) or internal infrastructure. * * Loopback (127.0.0.0/8, ::1) is intentionally ALLOWED — local dev policy * servers are a primary HTTP hook use case. * * When a global proxy or the sandbox network proxy is in use, the guard is * effectively bypassed for the target host because the proxy performs DNS * resolution. The sandbox proxy enforces its own domain allowlist. */ /** * Returns true if the address is in a range that HTTP hooks should not reach. * * Blocked IPv4: * 0.0.0.0/8 "this" network * 10.0.0.0/8 private * 100.64.0.0/10 shared address space / CGNAT (some cloud metadata, e.g. Alibaba 100.100.100.200) * 169.254.0.0/16 link-local (cloud metadata) * 172.16.0.0/12 private * 192.168.0.0/16 private * * Blocked IPv6: * :: unspecified * fc00::/7 unique local * fe80::/10 link-local * ::ffff: mapped IPv4 in a blocked range * * Allowed (returns false): * 127.0.0.0/8 loopback (local dev hooks) * ::1 loopback * everything else */ export function isBlockedAddress(address: string): boolean { const v = isIP(address) if (v === 4) { return isBlockedV4(address) } if (v === 6) { return isBlockedV6(address) } // Not a valid IP literal — let the real DNS path handle it (this function // is only called on results from dns.lookup, which always returns valid IPs) return false } function isBlockedV4(address: string): boolean { const parts = address.split('.').map(Number) const [a, b] = parts if ( parts.length !== 4 || a === undefined || b === undefined || parts.some(n => Number.isNaN(n)) ) { return false } // Loopback explicitly allowed if (a === 127) return false // 0.0.0.0/8 if (a === 0) return true // 10.0.0.0/8 if (a === 10) return true // 169.254.0.0/16 — link-local, cloud metadata if (a === 169 && b === 254) return true // 172.16.0.0/12 if (a === 172 && b >= 16 && b <= 31) return true // 100.64.0.0/10 — shared address space (RFC 6598, CGNAT). Some cloud // providers use this range for metadata endpoints (e.g. Alibaba Cloud at // 100.100.100.200). if (a === 100 && b >= 64 && b <= 127) return true // 192.168.0.0/16 if (a === 192 && b === 168) return true return false } function isBlockedV6(address: string): boolean { const lower = address.toLowerCase() // ::1 loopback explicitly allowed if (lower === '::1') return false // :: unspecified if (lower === '::') return true // IPv4-mapped IPv6 (0:0:0:0:0:ffff:X:Y in any representation — ::ffff:a.b.c.d, // ::ffff:XXXX:YYYY, expanded, or partially expanded). Extract the embedded // IPv4 address and delegate to the v4 check. Without this, hex-form mapped // addresses (e.g. ::ffff:a9fe:a9fe = 169.254.169.254) bypass the guard. const mappedV4 = extractMappedIPv4(lower) if (mappedV4 !== null) { return isBlockedV4(mappedV4) } // fc00::/7 — unique local addresses (fc00:: through fdff::) if (lower.startsWith('fc') || lower.startsWith('fd')) { return true } // fe80::/10 — link-local. The /10 means fe80 through febf, but the first // hextet is always fe80 in practice (RFC 4291 requires the next 54 bits // to be zero). Check both to be safe. const firstHextet = lower.split(':')[0] if ( firstHextet && firstHextet.length === 4 && firstHextet >= 'fe80' && firstHextet <= 'febf' ) { return true } return false } /** * Expand `::` and optional trailing dotted-decimal so an IPv6 address is * represented as exactly 8 hex groups. Returns null if expansion is not * well-formed (the caller has already validated with isIP, so this is * defensive). */ function expandIPv6Groups(addr: string): number[] | null { // Handle trailing dotted-decimal IPv4 (e.g. ::ffff:169.254.169.254). // Replace it with its two hex groups so the rest of the expansion is uniform. let tailHextets: number[] = [] if (addr.includes('.')) { const lastColon = addr.lastIndexOf(':') const v4 = addr.slice(lastColon + 1) addr = addr.slice(0, lastColon) const octets = v4.split('.').map(Number) if ( octets.length !== 4 || octets.some(n => !Number.isInteger(n) || n < 0 || n > 255) ) { return null } tailHextets = [ (octets[0]! << 8) | octets[1]!, (octets[2]! << 8) | octets[3]!, ] } // Expand `::` (at most one) into the right number of zero groups. const dbl = addr.indexOf('::') let head: string[] let tail: string[] if (dbl === -1) { head = addr.split(':') tail = [] } else { const headStr = addr.slice(0, dbl) const tailStr = addr.slice(dbl + 2) head = headStr === '' ? [] : headStr.split(':') tail = tailStr === '' ? [] : tailStr.split(':') } const target = 8 - tailHextets.length const fill = target - head.length - tail.length if (fill < 0) return null const hex = [...head, ...new Array(fill).fill('0'), ...tail] const nums = hex.map(h => parseInt(h, 16)) if (nums.some(n => Number.isNaN(n) || n < 0 || n > 0xffff)) { return null } nums.push(...tailHextets) return nums.length === 8 ? nums : null } /** * Extract the embedded IPv4 address from an IPv4-mapped IPv6 address * (0:0:0:0:0:ffff:X:Y) in any valid representation — compressed, expanded, * hex groups, or trailing dotted-decimal. Returns null if the address is * not an IPv4-mapped IPv6 address. */ function extractMappedIPv4(addr: string): string | null { const g = expandIPv6Groups(addr) if (!g) return null // IPv4-mapped: first 80 bits zero, next 16 bits ffff, last 32 bits = IPv4 if ( g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff ) { const hi = g[6]! const lo = g[7]! return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}` } return null } /** * A dns.lookup-compatible function that resolves a hostname and rejects * addresses in blocked ranges. Used as the `lookup` option in axios request * config so that the validated IP is the one the socket connects to — no * rebinding window between validation and connection. * * IP literals in the hostname are validated directly without DNS. * * Signature matches axios's `lookup` config option (not Node's dns.lookup). */ export function ssrfGuardedLookup( hostname: string, options: object, callback: ( err: Error | null, address: AxiosLookupAddress | AxiosLookupAddress[], family?: AddressFamily, ) => void, ): void { const wantsAll = 'all' in options && options.all === true // If hostname is already an IP literal, validate it directly. dns.lookup // would short-circuit too, but checking here gives a clearer error and // avoids any platform-specific lookup behavior for literals. const ipVersion = isIP(hostname) if (ipVersion !== 0) { if (isBlockedAddress(hostname)) { callback(ssrfError(hostname, hostname), '') return } const family = ipVersion === 6 ? 6 : 4 if (wantsAll) { callback(null, [{ address: hostname, family }]) } else { callback(null, hostname, family) } return } dnsLookup(hostname, { all: true }, (err, addresses) => { if (err) { callback(err, '') return } for (const { address } of addresses) { if (isBlockedAddress(address)) { callback(ssrfError(hostname, address), '') return } } const first = addresses[0] if (!first) { callback( Object.assign(new Error(`ENOTFOUND ${hostname}`), { code: 'ENOTFOUND', hostname, }), '', ) return } const family = first.family === 6 ? 6 : 4 if (wantsAll) { callback( null, addresses.map(a => ({ address: a.address, family: a.family === 6 ? 6 : 4, })), ) } else { callback(null, first.address, family) } }) } function ssrfError(hostname: string, address: string): NodeJS.ErrnoException { const err = new Error( `HTTP hook blocked: ${hostname} resolves to ${address} (private/link-local address). Loopback (127.0.0.1, ::1) is allowed for local dev.`, ) return Object.assign(err, { code: 'ERR_HTTP_HOOK_BLOCKED_ADDRESS', hostname, address, }) }