/ src / utils / hooks / ssrfGuard.ts
ssrfGuard.ts
  1  import type { AddressFamily, LookupAddress as AxiosLookupAddress } from 'axios'
  2  import { lookup as dnsLookup } from 'dns'
  3  import { 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   */
 42  export 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  
 55  function 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  
 88  function 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   */
133  function 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   */
187  function 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   */
216  export 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  
285  function 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  }