/** * packages/shared — outbound-URL SSRF guard (TASK-0112 SEND_WEBHOOK). * * Automation actions that POST to a caller-supplied URL (SEND_WEBHOOK) must * never reach internal / link-local / cloud-metadata endpoints. Two layers: * * 1. SAVE-TIME (no network): {@link assertPublicUrlString} — rejects http://, * private-range IP literals, loopback, link-local, the metadata range, and * common internal hostnames (`localhost`, `*.internal`, `metadata…`). This * runs in the API's config validation (422) and in the worker's executor * (fatal SSRF_BLOCKED), so a URL that passes save still fails closed at * runtime if DNS later resolves to a private address (rebinding defense). * * 2. RUNTIME: {@link assertPublicUrl} — resolves the hostname (DNS) and * rejects if ANY resolved address is private. Called before the HTTP * request and after every redirect hop (the executor follows redirects * itself so it can re-check each hop). * * Pure + side-effect-free except the DNS lookup (which is the whole point). */ import { lookup } from "node:dns/promises"; export class SsrfError extends Error { constructor( public readonly code: "SSRF_BLOCKED", message: string ) { super(message); this.name = "SsrfError"; } } /** True for the well-known loopback / metadata hostnames (no DNS needed). */ const BLOCKED_HOSTNAMES = new Set([ "localhost", "ip6-localhost", "ip4.local", "ip6.local", "metadata", "metadata.google.internal", "instance-data", "instance-data.rhcloud.com" ]); function hostIsBlockedName(hostname: string): boolean { const h = hostname.toLowerCase(); if (BLOCKED_HOSTNAMES.has(h)) return true; if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localhost")) return true; // AWS / GCP / Azure metadata hostnames. if (h === "metadata.google.internal" || h.endsWith(".compute.internal")) return true; return false; } /** * Classify an IP address (v4 or v6) as public/private. * Private ranges (spec TASK-0112): 10/8, 172.16/12, 192.168/16, 127/8, * 169.254/16, 0.0.0.0/8, ::1, fc00::/7, fe80::/10, ::/128. */ export function isPrivateIp(ip: string): boolean { const v6 = expandIpv6(ip); if (v6) return isPrivateV6(v6); const parts = ip.split(".").map((n) => Number(n)); if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; // unparseable → fail closed const [a, b] = parts as [number, number, number, number]; if (a === 0) return true; // 0.0.0.0/8 if (a === 10) return true; // 10/8 if (a === 127) return true; // 127/8 if (a === 169 && b === 254) return true; // 169.254/16 link-local (metadata) if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12 if (a === 192 && b === 168) return true; // 192.168/16 return false; } /** Expand a full IPv6 address to 8 groups (for prefix math). Null if not IPv6. */ function expandIpv6(ip: string): number[] | null { if (!ip.includes(":")) return null; // Strip zone id. const raw = ip.split("%")[0]!; // Handle :: shorthand. const halves = raw.split("::"); if (halves.length > 2) return null; const left = halves[0] ? halves[0]!.split(":") : []; const right = halves.length === 2 && halves[1] ? halves[1]!.split(":") : []; const groups = [...left, ...right]; if (halves.length === 2 && groups.length > 7) return null; const fill = 8 - groups.length; const all = halves.length === 2 ? [...left, ...Array(fill).fill("0"), ...right] : groups; if (all.length !== 8) return null; const nums = all.map((g) => { if (!/^[0-9a-f]{0,4}$/i.test(g)) return NaN; return g === "" ? 0 : parseInt(g, 16); }); if (nums.some((n) => Number.isNaN(n))) return null; return nums; } function isPrivateV6(groups: number[]): boolean { const first = groups[0]!; // ::/128 unspecified if (groups.every((g) => g === 0)) return true; // ::1 loopback (seven zero groups, last = 1) if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true; // fc00::/7 (unique-local): top 7 bits of the first 16-bit group = 1111111. if ((first & 0xfe00) === 0xfc00) return true; // fe80::/10 link-local if ((first & 0xffc0) === 0xfe80) return true; // 64:ff9b::/96 (NAT64) — treat as non-public to be safe. if (first === 0x64 && groups[1] === 0xff9b) return true; return false; } /** * Save-time / pre-connect check WITHOUT DNS: reject http, blocked hostnames, * and private-range IP-literal hosts. (IPv6-mapped / bracketed hosts are * parsed; unknown hostnames pass here and are caught by the runtime DNS check.) */ export function assertPublicUrlString(url: string): void { let u: URL; try { u = new URL(url); } catch { throw new SsrfError("SSRF_BLOCKED", "invalid URL"); } if (u.protocol !== "https:") { throw new SsrfError("SSRF_BLOCKED", `URL must be https (got ${u.protocol})`); } const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); if (hostIsBlockedName(host)) { throw new SsrfError("SSRF_BLOCKED", `blocked hostname: ${host}`); } // IP literal? (v4 dotted or v6 with colons) if (!host.includes(":") && /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) { if (isPrivateIp(host)) throw new SsrfError("SSRF_BLOCKED", `private IP: ${host}`); } else if (host.includes(":")) { const v6 = expandIpv6(host); if (v6 && isPrivateV6(v6)) throw new SsrfError("SSRF_BLOCKED", `private IPv6: ${host}`); } } /** * Runtime check: resolve the hostname and reject if ANY address is private * (DNS-rebinding defense). Resolves with all:true so a multi-A-record host * cannot hide a private address behind a public one. */ export async function assertPublicUrl(url: string): Promise { assertPublicUrlString(url); let u: URL; try { u = new URL(url); } catch { throw new SsrfError("SSRF_BLOCKED", "invalid URL"); } const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); if (hostIsBlockedName(host)) throw new SsrfError("SSRF_BLOCKED", `blocked hostname: ${host}`); // IP literal — check directly (no DNS). if (!host.includes(":") && /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) { if (isPrivateIp(host)) throw new SsrfError("SSRF_BLOCKED", `private IP: ${host}`); return; } if (host.includes(":")) { const v6 = expandIpv6(host); if (v6 && isPrivateV6(v6)) throw new SsrfError("SSRF_BLOCKED", `private IPv6: ${host}`); return; } // Real hostname → DNS resolve and check every record. let addrs: { address: string }[]; try { addrs = await lookup(host, { all: true }); } catch { // NXDOMAIN / no resolution → the connect will fail anyway; fail closed. throw new SsrfError("SSRF_BLOCKED", `cannot resolve host: ${host}`); } if (addrs.length === 0) throw new SsrfError("SSRF_BLOCKED", `no addresses for: ${host}`); for (const a of addrs) { if (isPrivateIp(a.address)) { throw new SsrfError("SSRF_BLOCKED", `host ${host} resolves to private IP ${a.address}`); } } }