String validation is not SSRF protection. Authorization must apply to the address actually connected after DNS resolution and every redirect.
SSRF-Safe URL Admission
Redirects are new destinations, not continuations of the original authorization.
// Admission returns the address the transport MUST connect to.
async function authorize(input: string) {
const url = new URL(input)
if (url.protocol !== 'https:') throw new Error('protocol denied')
const hostname = url.hostname.replace(/^\[|\]$/g, '')
const family = net.isIP(hostname)
const answers = family
? [{ address: hostname, family }]
: await dns.promises.lookup(hostname, { all: true, verbatim: true })
const allowed = answers.filter(({ address }) => isPublicRoutable(normalizeIp(address)))
if (!allowed.length) throw new Error('destination denied')
return { url, hostname, ...allowed[crypto.randomInt(allowed.length)] }
}
const target = await authorize(input)
const request = https.request({
hostname: target.address, family: target.family,
port: target.url.port || 443,
path: target.url.pathname + target.url.search,
headers: { host: target.url.host },
servername: net.isIP(target.hostname) ? undefined : target.hostname,
rejectUnauthorized: true, signal: AbortSignal.timeout(2_000),
})
request.end()
// Disable automatic redirects. Re-run authorize() for every Location.
// Enforce a response-byte ceiling before buffering the body.
// A proxy can bypass this pin: enforce the same policy at actual egress.Invariant: Every network destination actually connected to is authorized before the connection is established.
Use when: Users can provide a webhook or fetch target.
Why this boundary matters
Validating a DNS answer is meaningless if the HTTP client resolves again. Authorization must bind the selected address to the actual socket connection.
Failure policy
| Boundary | Action |
|---|---|
| Protocol not allowed | Reject before resolution |
| Resolved address is private or reserved | Reject |
| No authorized address remains | Reject |
| Redirect returned | Treat it as a new authorization decision |
| DNS answer changes | Re-resolve and connect only to an authorized address |
| IPv4/IPv6 mismatch | Normalize and validate both address families |
| Proxy or service mesh involved | Enforce policy at the actual egress boundary |
| Timeout exceeded | Abort |
| Response exceeds size limit | Abort and discard |
Trade-offs
Hostname and IP deny lists are defense-in-depth, not a complete boundary. DNS rebinding, redirects, IPv6, IPv4-mapped IPv6, proxies, service meshes, cloud metadata endpoints, and alternate IP representations make them fragile. Prefer destination allowlists; use an isolated egress proxy or network policy when arbitrary destinations are required.
Decision rule: Do not expose a general-purpose server-side fetcher to untrusted input. Constrain destination, protocol, resolution, redirects, time, response size, and egress.