Server-side allowlist — but checking the hostname string alone is not enough. DNS resolves at request time, so
http://attacker.com/ can point anywhere the attacker wants, including 127.0.0.1 — the exact bypass in the box above. The hostname must be
resolved, the resulting IP validated, and that same IP used for the actual connection (never re-resolved), or an attacker can swap the DNS answer between your check and your fetch:
// Node.js defense
const dns = require('dns').promises;
const net = require('net');
const ALLOWED_HOSTS = ['trusted-cdn.com', 'partner-api.com'];
function isPrivateOrLinkLocal(ip) {
// Blocks loopback, RFC1918 private ranges, and the
// 169.254.0.0/16 link-local range (AWS/GCP/Azure metadata)
return /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)
|| ip === '::1';
}
async function safeFetch(urlString) {
const url = new URL(urlString); // throws on invalid URL
// 1. Allowlist the hostname itself first
if (!ALLOWED_HOSTS.includes(url.hostname)) {
throw new Error('Host not in allowlist');
}
// 2. Resolve DNS and validate the ACTUAL destination IP —
// a hostname string alone proves nothing about where it points
const { address } = await dns.lookup(url.hostname);
if (isPrivateOrLinkLocal(address)) {
throw new Error('Forbidden destination (resolves to internal range)');
}
// 3. Connect using the already-validated IP, not the hostname again —
// if the HTTP client re-resolves the hostname itself, a DNS
// rebinding attack can swap the answer between step 2 and the
// real request. Most HTTP libraries support pinning the connection
// IP while still sending the correct Host header for TLS/SNI.
return fetchWithPinnedIP(urlString, address);
}
Also: disable HTTP redirects in your fetch client (a 3xx response can redirect to an internal address after the checks above already passed), and run your fetch worker in a network namespace or sandboxed egress proxy with no route to internal services as a defense-in-depth backstop.