fix(backend): close two SSRF bypasses in webhook URL validation

1. The URL guard only classified IP-literal hostnames; any plain domain
   name (the common case) skipped validation entirely. Hostnames are now
   resolved via DNS and every returned address is checked against the
   same private/loopback/link-local ranges (net.BlockList), so an
   attacker-controlled domain pointed at an internal IP is rejected too.
2. fetch() follows redirects by default, so a registered public URL could
   302 to an internal target at delivery time. Delivery now uses
   redirect: 'manual' so such a redirect is never followed.
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:08:40 +02:00
1 parent a0f7573af5
commit 79010a926b
4 files changed
+157 -71

No files matched your search

@@ -1,46 +1,56 @@
import { BadRequestException } from '@nestjs/common';
import * as dns from 'node:dns/promises';
import * as net from 'node:net';
const BLOCKED_HOSTNAMES = new Set(['localhost', '0.0.0.0']);
const BLOCKED_HOSTNAMES = new Set(['localhost']);
function isPrivateOrReservedIpv4(octets: number[]): boolean {
const [a, b] = octets;
if (a === 127) return true; // loopback
if (a === 10) return true; // private (RFC 1918)
if (a === 172 && b >= 16 && b <= 31) return true; // private (RFC 1918)
if (a === 192 && b === 168) return true; // private (RFC 1918)
if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata (169.254.169.254)
if (a === 0) return true; // "this network"
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;
}
function isPrivateOrReservedIp(ip: string): boolean {
if (net.isIPv4(ip)) {
return isPrivateOrReservedIpv4(ip.split('.').map(Number));
}
if (net.isIPv6(ip)) {
const normalized = ip.toLowerCase();
if (normalized === '::1' || normalized === '::') return true; // loopback / unspecified
if (normalized.startsWith('fe80:')) return true; // link-local
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // unique local
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized);
if (mapped && net.isIPv4(mapped[1])) {
return isPrivateOrReservedIpv4(mapped[1].split('.').map(Number));
}
return false;
}
return false;
interface LookupAddress {
address: string;
family: number;
}
type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
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.
* Checks are syntactic only (no DNS lookups), relying on the WHATWG URL
* parser to normalize decimal/octal/hex IPv4 shorthand into dotted form
* before the range check runs; DNS-rebinding (a hostname resolving to a
* private address only at delivery time) is not covered.
*
* 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 function assertPublicWebhookUrl(rawUrl: string): void {
export async function assertPublicWebhookUrl(
rawUrl: string,
lookup: LookupFn = defaultLookup,
): Promise<void> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
@@ -54,13 +64,30 @@ export function assertPublicWebhookUrl(rawUrl: string): void {
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (BLOCKED_HOSTNAMES.has(hostname)) {
throw new BadRequestException(
'Webhook URL must not target localhost or a reserved address',
);
throw new BadRequestException('Webhook URL must not target localhost');
}
if (net.isIP(hostname) && isPrivateOrReservedIp(hostname)) {
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 target a private, loopback, or link-local address',
'Webhook URL must not resolve to a private, loopback, or link-local address',
);
}
}