π File detail
utils/hooks/ssrfGuard.ts
π§© .tsπ 295 linesπΎ 8,732 bytesπ text
β Back to All Filesπ― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes isBlockedAddress and ssrfGuardedLookup β mainly functions, hooks, or classes. Dependencies touch HTTP client, dns, and Node networking.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import type { AddressFamily, LookupAddress as AxiosLookupAddress } from 'axios' import { lookup as dnsLookup } from 'dns' import { isIP } from 'net' /**
π€ Exports (heuristic)
isBlockedAddressssrfGuardedLookup
π External import roots
Package roots from from "β¦" (relative paths omitted).
axiosdnsnet
π₯οΈ Source preview
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:<v4> 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<string>(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,
})
}