fix(backend): block SSRF via webhook subscription URLs

WebhooksService.create() rejected obviously private, loopback, or
link-local URLs (incl. cloud metadata 169.254.169.254 and the
decimal/octal/hex IPv4 shorthand encodings the WHATWG URL parser
normalizes) so an admin-configured webhook can no longer be used to
proxy requests into internal infrastructure. Registration-time only,
no DNS resolution (DNS-rebinding is out of scope for this check).
This commit is contained in:
Nick Szaunig committed 2026-08-23 07:05:53 +02:00
1 parent 2458927d38
commit a0f7573af5
4 files changed
+171 -4

No files matched your search

@@ -0,0 +1,66 @@
import { BadRequestException } from '@nestjs/common';
import * as net from 'node:net';
const BLOCKED_HOSTNAMES = new Set(['localhost', '0.0.0.0']);
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"
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;
}
/**
* 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.
*/
export function assertPublicWebhookUrl(rawUrl: string): void {
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 or a reserved address',
);
}
if (net.isIP(hostname) && isPrivateOrReservedIp(hostname)) {
throw new BadRequestException(
'Webhook URL must not target a private, loopback, or link-local address',
);
}
}