source dump of claude code
at main 294 lines 8.7 kB view raw
1import type { AddressFamily, LookupAddress as AxiosLookupAddress } from 'axios' 2import { lookup as dnsLookup } from 'dns' 3import { isIP } from 'net' 4 5/** 6 * SSRF guard for HTTP hooks. 7 * 8 * Blocks private, link-local, and other non-routable address ranges to prevent 9 * project-configured HTTP hooks from reaching cloud metadata endpoints 10 * (169.254.169.254) or internal infrastructure. 11 * 12 * Loopback (127.0.0.0/8, ::1) is intentionally ALLOWED — local dev policy 13 * servers are a primary HTTP hook use case. 14 * 15 * When a global proxy or the sandbox network proxy is in use, the guard is 16 * effectively bypassed for the target host because the proxy performs DNS 17 * resolution. The sandbox proxy enforces its own domain allowlist. 18 */ 19 20/** 21 * Returns true if the address is in a range that HTTP hooks should not reach. 22 * 23 * Blocked IPv4: 24 * 0.0.0.0/8 "this" network 25 * 10.0.0.0/8 private 26 * 100.64.0.0/10 shared address space / CGNAT (some cloud metadata, e.g. Alibaba 100.100.100.200) 27 * 169.254.0.0/16 link-local (cloud metadata) 28 * 172.16.0.0/12 private 29 * 192.168.0.0/16 private 30 * 31 * Blocked IPv6: 32 * :: unspecified 33 * fc00::/7 unique local 34 * fe80::/10 link-local 35 * ::ffff:<v4> mapped IPv4 in a blocked range 36 * 37 * Allowed (returns false): 38 * 127.0.0.0/8 loopback (local dev hooks) 39 * ::1 loopback 40 * everything else 41 */ 42export function isBlockedAddress(address: string): boolean { 43 const v = isIP(address) 44 if (v === 4) { 45 return isBlockedV4(address) 46 } 47 if (v === 6) { 48 return isBlockedV6(address) 49 } 50 // Not a valid IP literal — let the real DNS path handle it (this function 51 // is only called on results from dns.lookup, which always returns valid IPs) 52 return false 53} 54 55function isBlockedV4(address: string): boolean { 56 const parts = address.split('.').map(Number) 57 const [a, b] = parts 58 if ( 59 parts.length !== 4 || 60 a === undefined || 61 b === undefined || 62 parts.some(n => Number.isNaN(n)) 63 ) { 64 return false 65 } 66 67 // Loopback explicitly allowed 68 if (a === 127) return false 69 70 // 0.0.0.0/8 71 if (a === 0) return true 72 // 10.0.0.0/8 73 if (a === 10) return true 74 // 169.254.0.0/16 — link-local, cloud metadata 75 if (a === 169 && b === 254) return true 76 // 172.16.0.0/12 77 if (a === 172 && b >= 16 && b <= 31) return true 78 // 100.64.0.0/10 — shared address space (RFC 6598, CGNAT). Some cloud 79 // providers use this range for metadata endpoints (e.g. Alibaba Cloud at 80 // 100.100.100.200). 81 if (a === 100 && b >= 64 && b <= 127) return true 82 // 192.168.0.0/16 83 if (a === 192 && b === 168) return true 84 85 return false 86} 87 88function isBlockedV6(address: string): boolean { 89 const lower = address.toLowerCase() 90 91 // ::1 loopback explicitly allowed 92 if (lower === '::1') return false 93 94 // :: unspecified 95 if (lower === '::') return true 96 97 // IPv4-mapped IPv6 (0:0:0:0:0:ffff:X:Y in any representation — ::ffff:a.b.c.d, 98 // ::ffff:XXXX:YYYY, expanded, or partially expanded). Extract the embedded 99 // IPv4 address and delegate to the v4 check. Without this, hex-form mapped 100 // addresses (e.g. ::ffff:a9fe:a9fe = 169.254.169.254) bypass the guard. 101 const mappedV4 = extractMappedIPv4(lower) 102 if (mappedV4 !== null) { 103 return isBlockedV4(mappedV4) 104 } 105 106 // fc00::/7 — unique local addresses (fc00:: through fdff::) 107 if (lower.startsWith('fc') || lower.startsWith('fd')) { 108 return true 109 } 110 111 // fe80::/10 — link-local. The /10 means fe80 through febf, but the first 112 // hextet is always fe80 in practice (RFC 4291 requires the next 54 bits 113 // to be zero). Check both to be safe. 114 const firstHextet = lower.split(':')[0] 115 if ( 116 firstHextet && 117 firstHextet.length === 4 && 118 firstHextet >= 'fe80' && 119 firstHextet <= 'febf' 120 ) { 121 return true 122 } 123 124 return false 125} 126 127/** 128 * Expand `::` and optional trailing dotted-decimal so an IPv6 address is 129 * represented as exactly 8 hex groups. Returns null if expansion is not 130 * well-formed (the caller has already validated with isIP, so this is 131 * defensive). 132 */ 133function expandIPv6Groups(addr: string): number[] | null { 134 // Handle trailing dotted-decimal IPv4 (e.g. ::ffff:169.254.169.254). 135 // Replace it with its two hex groups so the rest of the expansion is uniform. 136 let tailHextets: number[] = [] 137 if (addr.includes('.')) { 138 const lastColon = addr.lastIndexOf(':') 139 const v4 = addr.slice(lastColon + 1) 140 addr = addr.slice(0, lastColon) 141 const octets = v4.split('.').map(Number) 142 if ( 143 octets.length !== 4 || 144 octets.some(n => !Number.isInteger(n) || n < 0 || n > 255) 145 ) { 146 return null 147 } 148 tailHextets = [ 149 (octets[0]! << 8) | octets[1]!, 150 (octets[2]! << 8) | octets[3]!, 151 ] 152 } 153 154 // Expand `::` (at most one) into the right number of zero groups. 155 const dbl = addr.indexOf('::') 156 let head: string[] 157 let tail: string[] 158 if (dbl === -1) { 159 head = addr.split(':') 160 tail = [] 161 } else { 162 const headStr = addr.slice(0, dbl) 163 const tailStr = addr.slice(dbl + 2) 164 head = headStr === '' ? [] : headStr.split(':') 165 tail = tailStr === '' ? [] : tailStr.split(':') 166 } 167 168 const target = 8 - tailHextets.length 169 const fill = target - head.length - tail.length 170 if (fill < 0) return null 171 172 const hex = [...head, ...new Array<string>(fill).fill('0'), ...tail] 173 const nums = hex.map(h => parseInt(h, 16)) 174 if (nums.some(n => Number.isNaN(n) || n < 0 || n > 0xffff)) { 175 return null 176 } 177 nums.push(...tailHextets) 178 return nums.length === 8 ? nums : null 179} 180 181/** 182 * Extract the embedded IPv4 address from an IPv4-mapped IPv6 address 183 * (0:0:0:0:0:ffff:X:Y) in any valid representation — compressed, expanded, 184 * hex groups, or trailing dotted-decimal. Returns null if the address is 185 * not an IPv4-mapped IPv6 address. 186 */ 187function extractMappedIPv4(addr: string): string | null { 188 const g = expandIPv6Groups(addr) 189 if (!g) return null 190 // IPv4-mapped: first 80 bits zero, next 16 bits ffff, last 32 bits = IPv4 191 if ( 192 g[0] === 0 && 193 g[1] === 0 && 194 g[2] === 0 && 195 g[3] === 0 && 196 g[4] === 0 && 197 g[5] === 0xffff 198 ) { 199 const hi = g[6]! 200 const lo = g[7]! 201 return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}` 202 } 203 return null 204} 205 206/** 207 * A dns.lookup-compatible function that resolves a hostname and rejects 208 * addresses in blocked ranges. Used as the `lookup` option in axios request 209 * config so that the validated IP is the one the socket connects to — no 210 * rebinding window between validation and connection. 211 * 212 * IP literals in the hostname are validated directly without DNS. 213 * 214 * Signature matches axios's `lookup` config option (not Node's dns.lookup). 215 */ 216export function ssrfGuardedLookup( 217 hostname: string, 218 options: object, 219 callback: ( 220 err: Error | null, 221 address: AxiosLookupAddress | AxiosLookupAddress[], 222 family?: AddressFamily, 223 ) => void, 224): void { 225 const wantsAll = 'all' in options && options.all === true 226 227 // If hostname is already an IP literal, validate it directly. dns.lookup 228 // would short-circuit too, but checking here gives a clearer error and 229 // avoids any platform-specific lookup behavior for literals. 230 const ipVersion = isIP(hostname) 231 if (ipVersion !== 0) { 232 if (isBlockedAddress(hostname)) { 233 callback(ssrfError(hostname, hostname), '') 234 return 235 } 236 const family = ipVersion === 6 ? 6 : 4 237 if (wantsAll) { 238 callback(null, [{ address: hostname, family }]) 239 } else { 240 callback(null, hostname, family) 241 } 242 return 243 } 244 245 dnsLookup(hostname, { all: true }, (err, addresses) => { 246 if (err) { 247 callback(err, '') 248 return 249 } 250 251 for (const { address } of addresses) { 252 if (isBlockedAddress(address)) { 253 callback(ssrfError(hostname, address), '') 254 return 255 } 256 } 257 258 const first = addresses[0] 259 if (!first) { 260 callback( 261 Object.assign(new Error(`ENOTFOUND ${hostname}`), { 262 code: 'ENOTFOUND', 263 hostname, 264 }), 265 '', 266 ) 267 return 268 } 269 270 const family = first.family === 6 ? 6 : 4 271 if (wantsAll) { 272 callback( 273 null, 274 addresses.map(a => ({ 275 address: a.address, 276 family: a.family === 6 ? 6 : 4, 277 })), 278 ) 279 } else { 280 callback(null, first.address, family) 281 } 282 }) 283} 284 285function ssrfError(hostname: string, address: string): NodeJS.ErrnoException { 286 const err = new Error( 287 `HTTP hook blocked: ${hostname} resolves to ${address} (private/link-local address). Loopback (127.0.0.1, ::1) is allowed for local dev.`, 288 ) 289 return Object.assign(err, { 290 code: 'ERR_HTTP_HOOK_BLOCKED_ADDRESS', 291 hostname, 292 address, 293 }) 294}