init
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user