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.
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
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<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.
|
|
*
|
|
* 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<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');
|
|
}
|
|
|
|
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',
|
|
);
|
|
}
|
|
}
|