import dns from "node:dns/promises"; import net from "node:net"; import { Agent } from "undici"; function isPrivateV4(a: number, b: number): boolean { if (a === 127) return true; if (a === 10) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 169 && b === 254) return true; if (a >= 224) return true; return false; } /** Expands a valid IPv6 address (any compression form) into 8 16-bit groups as a BigInt. */ function ipv6ToBigInt(ip: string): bigint | null { if (net.isIPv6(ip) !== true) return null; const [head, tail] = ip.split("::"); const headParts = head ? head.split(":") : []; const tailParts = tail ? tail.split(":") : []; // An embedded IPv4 tail (e.g. "::ffff:127.0.0.1") occupies the last two hextets. const expand = (parts: string[]): string[] => { const last = parts[parts.length - 1]; if (last && last.includes(".")) { const octets = last.split(".").map(Number); if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) { return []; } const hex1 = ((octets[0]! << 8) | octets[1]!).toString(16); const hex2 = ((octets[2]! << 8) | octets[3]!).toString(16); return [...parts.slice(0, -1), hex1, hex2]; } return parts; }; const expandedHead = expand(headParts); const expandedTail = expand(tailParts); let groups: string[]; if (ip.includes("::")) { const missing = 8 - (expandedHead.length + expandedTail.length); if (missing < 0) return null; groups = [...expandedHead, ...Array(missing).fill("0"), ...expandedTail]; } else { groups = expandedHead; } if (groups.length !== 8) return null; let result = BigInt(0); for (const g of groups) { const val = parseInt(g || "0", 16); if (Number.isNaN(val)) return null; result = (result << BigInt(16)) | BigInt(val); } return result; } export function isPrivateAddress(ip: string): boolean { const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (v4) { const octets = v4.slice(1, 5).map(Number); if (octets.some((o) => o > 255)) return true; // malformed, fail closed const [a, b] = octets as [number, number]; return isPrivateV4(a, b); } const addr = ipv6ToBigInt(ip); if (addr === null) return true; // unparseable, fail closed if (addr === BigInt(0) || addr === BigInt(1)) return true; // :: and ::1 const fc00 = BigInt(0xfc00) << BigInt(112); const fe80 = BigInt(0xfe80) << BigInt(112); const mask7 = BigInt(0xfe00) << BigInt(112); // /7 mask for fc00::/7 (top 7 bits of the address) const mask10 = BigInt(0xffc0) << BigInt(112); // /10 mask for fe80::/10 (top 10 bits of the address) if ((addr & mask7) === (fc00 & mask7)) return true; // unique local fc00::/7 if ((addr & mask10) === (fe80 & mask10)) return true; // link-local fe80::/10 // IPv4-mapped ::ffff:0:0/96 if (addr >> BigInt(32) === BigInt(0xffff)) { const embedded = addr & BigInt(0xffffffff); const a = Number((embedded >> BigInt(24)) & BigInt(0xff)); const b = Number((embedded >> BigInt(16)) & BigInt(0xff)); return isPrivateV4(a, b); } return false; } /** * Returns an error string if the URL is disallowed (SSRF), or null if safe. * Blocks non-http(s) schemes, RFC-1918, loopback, link-local, cloud metadata. */ export async function validateWebhookUrl(rawUrl: string): Promise { let url: URL; try { url = new URL(rawUrl); } catch { return "Invalid URL"; } if (url.protocol !== "https:" && url.protocol !== "http:") { return "Webhook URL must use http or https"; } let addresses: string[]; try { const results = await dns.lookup(url.hostname, { all: true, family: 0 }); addresses = results.map((r) => r.address); } catch { return "Unable to resolve webhook hostname"; } for (const addr of addresses) { if (isPrivateAddress(addr)) { return "Webhook URL must not point to a private or reserved address"; } } return null; } /** Resolves hostname once and returns the first non-private address, or throws. */ async function resolvePinnedAddress(hostname: string): Promise<{ address: string; family: number }> { let results: { address: string; family: number }[]; try { results = await dns.lookup(hostname, { all: true, family: 0 }); } catch { throw new Error("Unable to resolve hostname"); } const safe = results.find((r) => !isPrivateAddress(r.address)); if (!safe) throw new Error("URL must not point to a private or reserved address"); return safe; } /** * SSRF-safe fetch: resolves the hostname once, validates the resolved IP, then * pins the actual connection to that exact IP (via a custom undici Agent lookup) * so a subsequent DNS re-resolution by the HTTP client can't be rebound to an * internal address. Redirects are followed manually, with each hop re-validated * and re-pinned the same way — so a 3xx response can't be used to reach an * internal service either. */ export async function safeFetch( rawUrl: string, init: { method?: string; headers?: Record; body?: string; signal?: AbortSignal } = {}, maxRedirects = 5 ): Promise { let currentUrl = rawUrl; for (let hop = 0; ; hop++) { let url: URL; try { url = new URL(currentUrl); } catch { throw new Error("Invalid URL"); } if (url.protocol !== "https:" && url.protocol !== "http:") { throw new Error("URL must use http or https"); } const { address, family } = await resolvePinnedAddress(url.hostname); const agent = new Agent({ connect: { lookup: (_hostname, _opts, callback) => { callback(null, [{ address, family: family as 4 | 6 }]); }, }, }); // Node's global fetch is undici under the hood and accepts the same // `dispatcher` extension, so this pins the connection without needing a // separate undici-specific fetch call. const res = await fetch(currentUrl, { ...init, redirect: "manual", // @ts-expect-error -- `dispatcher` is a Node/undici fetch extension, not in the lib.dom.d.ts RequestInit type dispatcher: agent, }); if (res.status >= 300 && res.status < 400) { if (hop >= maxRedirects) throw new Error("Too many redirects"); const location = res.headers.get("location"); if (!location) throw new Error("Redirect with no Location header"); currentUrl = new URL(location, currentUrl).toString(); continue; } return res; } }