| |
| |
| |
| |
| |
| |
| import { promises as dns } from 'node:dns'; |
| import { isIP } from 'node:net'; |
|
|
| function normalizeAddress(value: string): string { |
| let normalized = value.trim().toLowerCase(); |
| if (normalized.startsWith('[') && normalized.endsWith(']')) { |
| normalized = normalized.slice(1, -1); |
| } |
| return normalized.replace(/\.+$/, ''); |
| } |
|
|
| function parseIPv4(ip: string): number[] | null { |
| const parts = ip.split('.'); |
| if (parts.length !== 4) return null; |
|
|
| const octets = parts.map((part) => { |
| if (!/^\d+$/.test(part)) { |
| return Number.NaN; |
| } |
| return Number.parseInt(part, 10); |
| }); |
|
|
| if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) { |
| return null; |
| } |
|
|
| return octets; |
| } |
|
|
| function extractMappedIPv4(ip: string): string | null { |
| const normalized = normalizeAddress(ip); |
| if (!normalized.startsWith('::ffff:')) { |
| return null; |
| } |
|
|
| const suffix = normalized.slice('::ffff:'.length); |
| const dottedIPv4 = parseIPv4(suffix); |
| if (dottedIPv4) { |
| return dottedIPv4.join('.'); |
| } |
|
|
| const parts = suffix.split(':'); |
| if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) { |
| return null; |
| } |
|
|
| const [high, low] = parts.map((part) => Number.parseInt(part, 16)); |
| return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.'); |
| } |
|
|
| function getFirstIPv6Hextet(ip: string): number | null { |
| const normalized = normalizeAddress(ip); |
| if (!normalized.includes(':')) { |
| return null; |
| } |
|
|
| if (normalized.startsWith('::')) { |
| return 0; |
| } |
|
|
| const [firstHextet] = normalized.split(':'); |
| if (!firstHextet || !/^[0-9a-f]{1,4}$/.test(firstHextet)) { |
| return null; |
| } |
|
|
| return Number.parseInt(firstHextet, 16); |
| } |
|
|
| |
| function expandIPv6(ip: string): number[] | null { |
| const normalized = normalizeAddress(ip); |
| if (!normalized.includes(':')) return null; |
|
|
| |
| const lastPart = normalized.split(':').pop() || ''; |
| if (lastPart.includes('.')) return null; |
|
|
| const sides = normalized.split('::'); |
| if (sides.length > 2) return null; |
|
|
| let parts: string[]; |
| if (sides.length === 2) { |
| const left = sides[0] ? sides[0].split(':') : []; |
| const right = sides[1] ? sides[1].split(':') : []; |
| const missing = 8 - left.length - right.length; |
| if (missing < 0) return null; |
| parts = [...left, ...Array(missing).fill('0'), ...right]; |
| } else { |
| parts = normalized.split(':'); |
| } |
|
|
| if (parts.length !== 8) return null; |
| if (parts.some((p) => !/^[0-9a-f]{1,4}$/.test(p))) return null; |
|
|
| return parts.map((p) => Number.parseInt(p, 16)); |
| } |
|
|
| export function isPrivateIP(ip: string): boolean { |
| const normalized = normalizeAddress(ip); |
| const mappedIPv4 = extractMappedIPv4(normalized); |
| if (mappedIPv4) { |
| return isPrivateIP(mappedIPv4); |
| } |
|
|
| const ipv4 = parseIPv4(normalized); |
| if (ipv4) { |
| const [first, second, third, fourth] = ipv4; |
| return ( |
| first === 0 || |
| first === 10 || |
| first === 127 || |
| (first === 169 && second === 254) || |
| (first === 172 && second >= 16 && second <= 31) || |
| (first === 192 && second === 168) || |
| (first === 0 && second === 0 && third === 0 && fourth === 0) |
| ); |
| } |
|
|
| const ipv6FirstHextet = getFirstIPv6Hextet(normalized); |
| if (ipv6FirstHextet === null) { |
| return false; |
| } |
|
|
| if (normalized === '::' || normalized === '::1') { |
| return true; |
| } |
|
|
| if ( |
| (ipv6FirstHextet & 0xfe00) === 0xfc00 || |
| (ipv6FirstHextet & 0xffc0) === 0xfe80 || |
| (ipv6FirstHextet & 0xffc0) === 0xfec0 |
| ) { |
| return true; |
| } |
|
|
| |
| if (ipv6FirstHextet === 0x2002) { |
| const hextets = expandIPv6(normalized); |
| if (hextets) { |
| const embedded = `${hextets[1] >> 8}.${hextets[1] & 0xff}.${hextets[2] >> 8}.${hextets[2] & 0xff}`; |
| if (isPrivateIP(embedded)) return true; |
| } |
| } |
|
|
| |
| if (ipv6FirstHextet === 0x2001) { |
| const hextets = expandIPv6(normalized); |
| if (hextets && hextets[1] === 0x0000) { |
| const high = hextets[6] ^ 0xffff; |
| const low = hextets[7] ^ 0xffff; |
| const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; |
| if (isPrivateIP(embedded)) return true; |
| } |
| } |
|
|
| return false; |
| } |
|
|
| |
| |
| |
| |
| export async function validateUrlForSSRF(url: string): Promise<string | null> { |
| let parsed: URL; |
| try { |
| parsed = new URL(url); |
| } catch { |
| return 'Invalid URL'; |
| } |
|
|
| if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { |
| return 'Only HTTP(S) URLs are allowed'; |
| } |
|
|
| |
| const allowLocal = process.env.ALLOW_LOCAL_NETWORKS; |
| if (allowLocal === 'true' || allowLocal === '1') { |
| return null; |
| } |
|
|
| const hostname = normalizeAddress(parsed.hostname); |
| if ( |
| hostname === 'localhost' || |
| hostname.endsWith('.local') || |
| hostname === '0.0.0.0' || |
| hostname === '::1' || |
| isPrivateIP(hostname) |
| ) { |
| return 'Local/private network URLs are not allowed'; |
| } |
|
|
| if (isIP(hostname)) { |
| return null; |
| } |
|
|
| let resolvedAddresses: Array<{ address: string; family: number }>; |
| try { |
| resolvedAddresses = await dns.lookup(hostname, { all: true, verbatim: true }); |
| } catch { |
| return 'Unable to verify hostname safety'; |
| } |
|
|
| if (resolvedAddresses.length === 0) { |
| return 'Unable to verify hostname safety'; |
| } |
|
|
| if (resolvedAddresses.some(({ address }) => isPrivateIP(address))) { |
| return 'Local/private network URLs are not allowed'; |
| } |
|
|
| return null; |
| } |
|
|