/** * SSRF guard for outbound webhook URLs. * * Validates that a target URL is not pointing to internal/private infrastructure * before allowing a webhook to be stored or dispatched. */ import { lookup } from "node:dns/promises"; import { TRPCError } from "@trpc/server"; /** Regex patterns matching IP ranges that must not be targeted. */ const BLOCKED_IP_PATTERNS: RegExp[] = [ // Loopback IPv4 /^127\./, // Loopback IPv6 /^::1$/, // RFC 1918 private /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, // Link-local /^169\.254\./, // Cloud metadata (AWS, GCP, Azure) /^100\.64\./, ]; /** Hostnames that must never be resolved or contacted. */ const BLOCKED_HOSTNAMES = new Set([ "localhost", "metadata.google.internal", "169.254.169.254", ]); function isBlockedIp(ip: string): boolean { return BLOCKED_IP_PATTERNS.some((re) => re.test(ip)); } /** * Throws a TRPCError if the given URL targets internal/private infrastructure. * Performs DNS resolution to catch attempts to bypass hostname checks. */ export async function assertWebhookUrlAllowed(urlString: string): Promise { let parsed: URL; try { parsed = new URL(urlString); } catch { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid webhook URL." }); } if (parsed.protocol !== "https:") { throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URLs must use HTTPS." }); } const hostname = parsed.hostname.toLowerCase(); if (BLOCKED_HOSTNAMES.has(hostname)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL target is not allowed." }); } // Resolve hostname and validate the resulting IP address try { const { address } = await lookup(hostname); if (isBlockedIp(address) || BLOCKED_HOSTNAMES.has(address)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL target is not allowed." }); } } catch (err) { if (err instanceof TRPCError) throw err; // DNS resolution failed — block by default (fail-closed) throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL could not be validated." }); } }