import { BadRequestException } from '@nestjs/common'; import * as dns from 'node:dns/promises'; import * as net from 'node:net'; const BLOCKED_HOSTNAMES = new Set(['localhost']); const blockedRanges = new net.BlockList(); blockedRanges.addSubnet('10.0.0.0', 8, 'ipv4'); // RFC 1918 blockedRanges.addSubnet('172.16.0.0', 12, 'ipv4'); // RFC 1918 blockedRanges.addSubnet('192.168.0.0', 16, 'ipv4'); // RFC 1918 blockedRanges.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback blockedRanges.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local, incl. cloud metadata blockedRanges.addSubnet('0.0.0.0', 8, 'ipv4'); // "this network" blockedRanges.addAddress('::1', 'ipv6'); // loopback blockedRanges.addAddress('::', 'ipv6'); // unspecified blockedRanges.addSubnet('fe80::', 10, 'ipv6'); // link-local blockedRanges.addSubnet('fc00::', 7, 'ipv6'); // unique local function isBlockedIp(ip: string): boolean { const version = net.isIP(ip); if (version === 4) return blockedRanges.check(ip, 'ipv4'); if (version === 6) return blockedRanges.check(ip, 'ipv6'); return false; } interface LookupAddress { address: string; family: number; } type LookupFn = (hostname: string) => Promise; const defaultLookup: LookupFn = (hostname) => dns.lookup(hostname, { all: true }); /** * Blocks webhook targets that would let an admin turn the backend into an * SSRF proxy against internal services or the cloud metadata endpoint. * * IP literals (including decimal/octal/hex shorthand, which the WHATWG URL * parser normalizes before this runs) are checked directly against * `net.BlockList`. Plain hostnames are resolved via DNS and every returned * address is checked too, so an attacker-controlled domain pointed at an * internal IP is rejected rather than silently passing the literal-only * check. This still does not defend against DNS rebinding (the same * hostname resolving to a different, private address only later, at actual * delivery time) — closing that would require re-resolving on every * delivery, which the caller intentionally does not do (see deliver()). */ export async function assertPublicWebhookUrl( rawUrl: string, lookup: LookupFn = defaultLookup, ): Promise { let parsed: URL; try { parsed = new URL(rawUrl); } catch { throw new BadRequestException('Invalid webhook URL'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new BadRequestException('Webhook URL must use http or https'); } const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); if (BLOCKED_HOSTNAMES.has(hostname)) { throw new BadRequestException('Webhook URL must not target localhost'); } if (net.isIP(hostname)) { if (isBlockedIp(hostname)) { throw new BadRequestException( 'Webhook URL must not target a private, loopback, or link-local address', ); } return; } let addresses: LookupAddress[]; try { addresses = await lookup(hostname); } catch { throw new BadRequestException('Webhook URL host could not be resolved'); } if ( addresses.length === 0 || addresses.some((record) => isBlockedIp(record.address)) ) { throw new BadRequestException( 'Webhook URL must not resolve to a private, loopback, or link-local address', ); } }