fix: validate URLs before outbound fetch
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
|
||||
const blockedAddressRanges = new BlockList();
|
||||
blockedAddressRanges.addAddress('0.0.0.0');
|
||||
blockedAddressRanges.addAddress('127.0.0.1');
|
||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set(['localhost']);
|
||||
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain'];
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4');
|
||||
if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only when the URL targets a public host reachable over http(s).
|
||||
* Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname
|
||||
* suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials,
|
||||
* and any hostname whose DNS resolves to a blocked address.
|
||||
*
|
||||
* Note: there is a TOCTOU window between this lookup and the eventual fetch().
|
||||
* Callers that need rebinding-safe behavior must additionally pin the resolved
|
||||
* IP at connect time (e.g. via a custom undici dispatcher).
|
||||
*/
|
||||
export async function isPublicHttpUrl(urlString: string): Promise<boolean> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (url.username || url.password) return false;
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
if (!hostname) return false;
|
||||
if (BLOCKED_HOSTNAMES.has(hostname)) return false;
|
||||
if (BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false;
|
||||
|
||||
if (isBlockedIpAddress(hostname)) return false;
|
||||
if (isIP(hostname)) return true;
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
if (records.length === 0) return false;
|
||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user