import { lookup } from 'dns/promises'; const MAX_REDIRECTS = 5; function isPrivateIp(ip: string): boolean { if ( ip === '127.0.0.1' || ip === '::1' || ip === '0.0.0.0' || ip === '::' ) return true; // 10.x.x.x if (ip.startsWith('10.')) return true; // 172.16.0.0 - 172.31.255.255 if (ip.startsWith('172.')) { const second = parseInt(ip.split('.')[1], 10); if (second >= 16 && second <= 31) return true; } // 192.168.x.x if (ip.startsWith('192.168.')) return true; // 169.254.x.x (link-local, AWS metadata) if (ip.startsWith('169.254.')) return true; // IPv6 private ranges (fc00::/7, fe80::/10) const lower = ip.toLowerCase(); if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80')) return true; return false; } export async function resolveAndValidateHost(url: string): Promise { let hostname: string; try { hostname = new URL(url).hostname; } catch { return false; } // Block obvious private hostnames if (hostname === 'localhost' || hostname === 'metadata.google.internal') return false; // If hostname is already an IP literal, check it directly if (/^[\d.]+$/.test(hostname) || hostname.startsWith('[')) return !isPrivateIp(hostname.replace(/[[\]]/g, '')); try { const { address } = await lookup(hostname); return !isPrivateIp(address); } catch { return false; } } export interface SafeFetchOptions { signal?: AbortSignal; headers?: Record; } /** * Fetches a URL while following redirects safely, validating each * hop against SSRF (private/reserved IPs, blocked hostnames). * * The caller must validate the initial URL with `resolveAndValidateHost` * before calling this function. */ export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise { let currentUrl = url; let response: Response | undefined; for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) { response = await fetch(currentUrl, { redirect: 'manual', signal: options.signal, headers: options.headers }); const location = response.headers.get('location'); if (response.status >= 300 && response.status < 400 && location) { let nextUrl: string; try { nextUrl = new URL(location, currentUrl).href; } catch { break; } if (!/^https?:\/\//i.test(nextUrl)) break; const redirectAllowed = await resolveAndValidateHost(nextUrl); if (!redirectAllowed) break; currentUrl = nextUrl; continue; } break; } return response; }