DNS Rebinding and IP Allowlist Bypass in SSRF Defense
The obvious way to stop server-side request forgery is to validate the target before fetching it: resolve the hostname, check the IP is not in a private range, and proceed. That control is a mirage. Between the moment you resolve the name to validate it and the moment the HTTP client resolves it again to open the socket, an attacker who controls the authoritative DNS for their domain can flip the answer — returning a harmless public IP for your check and an internal address like 169.254.169.254 for the actual connection. This is DNS rebinding, a classic time-of-check-to-time-of-use race, and it defeats naive allowlists completely. This guide shows the bypass mechanics and the robust fix: resolve once, pin the IP, and re-validate at connect time. It is part of the Server-Side Request Forgery (SSRF) Prevention guide within Vulnerability Patterns & Web Mitigation Strategies.
Because SSRF is ultimately a boundary violation between your service and internal resources, this pairs with defining trust boundaries, which frames why the cloud metadata endpoint sits on the far side of a line the fetcher must never cross.
Prerequisites
- A server-side feature that fetches user-supplied URLs (webhooks, link previews, importers)
- A runtime where you can supply a custom HTTP agent or connection callback (Node.js, Python, Go)
- The ability to disable automatic redirect following in your HTTP client
- A private-range denylist covering RFC 1918, loopback, link-local, and IPv6 equivalents
Expected Outcomes
- Hostnames are resolved exactly once and the request connects to that pinned IP
- The peer IP is re-checked against the denylist at the moment the socket connects
- Redirects are not followed automatically; each hop is re-validated explicitly
- Decimal, IPv6-mapped, and rebinding tricks all fail closed
Step 1: Resolve Once and Pin the Resolved IP
The root cause of the bypass is two DNS lookups: one for your validation and a second, uncontrolled one inside the HTTP client. The fix is to perform a single resolution yourself, validate that concrete IP, and then connect to that exact address — never handing the raw hostname to the client where it would resolve again.
The timeline below shows the race the pin eliminates.
import { promises as dns } from 'node:dns';
import net from 'node:net';
import ipaddr from 'ipaddr.js';
function isForbidden(ip: string): boolean {
const addr = ipaddr.parse(ip);
const range = addr.range(); // 'private', 'loopback', 'linkLocal', 'uniqueLocal', ...
return ['private', 'loopback', 'linkLocal', 'uniqueLocal',
'carrierGradeNat', 'reserved', 'unspecified'].includes(range);
}
// Resolve ONCE; return a single validated IP to connect to.
export async function resolveAndPin(hostname: string): Promise<string> {
const records = await dns.lookup(hostname, { all: true });
if (records.length === 0) throw new Error('no_dns_records');
for (const { address } of records) {
if (isForbidden(address)) {
throw new Error(`blocked_ip:${address}`); // fail closed on ANY bad record
}
}
return records[0].address; // the pinned IP the socket will use
}
Rejecting the hostname if any returned record is forbidden closes the multi-record variant, where an attacker returns one public and one internal address hoping the client picks the internal one.
Parsing the address before checking it is what defeats the encoding tricks that string-based allowlists miss. 169.254.169.254 can be written as the decimal integer 2852039166, as 0xA9FEA9FE in hex, as 0251.0376.0251.0376 in octal, as the IPv6-mapped form ::ffff:169.254.169.254, or with a trailing dot. A denylist that compares raw strings waves all of these through, whereas parsing with a dedicated IP library normalizes every representation to the same canonical address and applies the range check to that. The rule is simple: never make a security decision on the textual form of an address — parse first, then evaluate the parsed value’s range.
Step 2: Re-validate the Peer IP at Socket Connect
Pinning tells the client which address to use, but a defense-in-depth implementation also checks the peer address at the exact moment the socket connects, so even a resolver you did not control cannot slip an internal IP through. In Node.js this is done with a custom agent whose lookup returns the pinned address, plus a connect/secureConnect assertion on the socket’s remote address. Decimal, octal, and IPv6-mapped encodings all normalize to a real address here, so they cannot smuggle past the check.
import http from 'node:http';
import { URL } from 'node:url';
export async function safeFetch(rawUrl: string): Promise<string> {
const url = new URL(rawUrl);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('protocol_not_allowed');
}
const pinnedIp = await resolveAndPin(url.hostname);
const agent = new http.Agent({
// Force the client to use the address we already validated.
lookup: (_host, _opts, cb) =>
cb(null, pinnedIp, net.isIPv6(pinnedIp) ? 6 : 4)
});
return new Promise((resolve, reject) => {
const req = http.request(url, {
agent,
headers: { host: url.host }, // preserve virtual host
// @ts-expect-error redirects handled manually — see Step 3
followRedirects: false
}, (res) => {
// Assert the socket actually landed on a safe IP.
const peer = res.socket.remoteAddress ?? '';
if (isForbidden(peer)) {
req.destroy();
return reject(new Error(`connect_blocked:${peer}`));
}
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => resolve(body));
});
req.on('error', reject);
req.end();
});
}
The remoteAddress assertion is the belt to the pin’s suspenders: it validates the address the kernel actually connected to, after any resolver behavior, so a TTL-0 rebind has nowhere left to act. For the broader allowlist and cloud metadata angle, see SSRF allowlists and metadata service protection.
Step 3: Disable Automatic Redirect Following
A pinned, validated first request can still be answered with 302 Location: http://169.254.169.254/. If the client follows that automatically, it resolves and connects to the new target with none of your checks running. Disable auto-redirects and, if you must follow them, re-run the full resolve-validate-pin routine on each hop.
async function fetchWithSafeRedirects(startUrl: string, maxHops = 3): Promise<string> {
let current = startUrl;
for (let hop = 0; hop <= maxHops; hop++) {
const { status, headers, body, socketIp } = await requestPinned(current);
if (isForbidden(socketIp)) throw new Error(`hop_blocked:${socketIp}`);
if (status >= 300 && status < 400 && headers.location) {
// Re-validate the redirect target from scratch — do NOT trust it.
current = new URL(headers.location, current).toString();
continue;
}
return body;
}
throw new Error('too_many_redirects');
}
Every hop is treated as a brand-new untrusted URL: resolve it, pin it, validate the connected peer, and only then read the body. Cap the number of hops and forbid protocol downgrades or scheme changes across redirects — a 302 from https: to file: or gopher: is a classic way to reach resources the HTTP validation never anticipated, so restrict every hop to http: and https: and reject anything else outright.
Naive allowlist vs pinned-IP defense
| Dimension | Naive hostname allowlist | Resolve-once + pin + connect check |
|---|---|---|
| DNS lookups per request | Two (validate, then client re-resolves) | One, reused for the connection |
| Vulnerable to TTL-0 rebinding | Yes, the race is wide open | No, the connection targets the checked IP |
| Decimal / octal / IPv6-mapped tricks | Often bypass string comparisons | Normalized and rejected at parse time |
| Redirect to internal host | Followed automatically, unchecked | Blocked or re-validated per hop |
| Multi-record (public + internal) DNS | May pick the internal record | Fails closed if any record is forbidden |
| Peer address actually connected to | Never verified | Asserted against the denylist at connect |
| Typical outcome | Metadata service reachable | Rebinding and encodings fail closed |
Verification
Prove the pinned agent rejects a rebinding domain and internal encodings:
# 1. Rebinding harness: a domain whose TTL-0 DNS flips public -> 169.254.169.254
node -e "require('./safeFetch').safeFetch('http://rebind.test.example/')" \
; echo "exit=$?"
# expect a thrown blocked_ip / connect_blocked error, non-zero exit
# 2. Direct internal targets and alternate encodings all fail closed
for t in http://169.254.169.254/ http://127.0.0.1/ http://2130706433/ http://[::ffff:127.0.0.1]/ ; do
node -e "require('./safeFetch').safeFetch('$t')" 2>&1 | grep -q blocked \
&& echo "BLOCKED $t" || echo "LEAK $t"
done
# expect: BLOCKED for every entry
Expected: the rebinding harness throws at connect time, and every internal or encoded address prints BLOCKED. A legitimate public URL returns its body normally.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Rebinding domain still reaches internal IP | Client resolves the hostname itself instead of using the pin | Supply the custom lookup on the agent so the pinned IP is used |
2130706433 (decimal for 127.0.0.1) slips through |
Denylist checks the string, not the parsed address | Parse with an IP library and check the normalized address’ range |
| Redirect to metadata endpoint succeeds | Client follows 3xx automatically | Disable auto-redirect; re-validate each hop with the full routine |
IPv6-mapped IPv4 ::ffff:169.254.169.254 bypasses |
Range check ignores mapped addresses | Normalize mapped addresses before the range check |
| Legitimate host with multiple A records is blocked | One record is public, another internal, and you fail closed | Correct behavior; ask the integrator to fix their DNS or allowlist the exact IP |
Common Implementation Mistakes
Frequently Asked Questions
Why does validating the hostname before the request not stop DNS rebinding?
Validating a hostname resolves it once to check the IP, but the HTTP client resolves it again when it opens the socket. Between those two lookups an attacker-controlled DNS server with a zero TTL can return a public IP for the check and an internal IP such as 169.254.169.254 for the connection. That gap between the check and the use is a time-of-check-to-time-of-use race, which hostname validation alone cannot close.
How does pinning the resolved IP prevent the rebinding race?
Pinning means you resolve the hostname yourself once, validate that specific IP against your private-range denylist, and then force the HTTP client to connect to exactly that address instead of resolving the name a second time. Because the connection targets the address you already validated, the attacker’s DNS server has no opportunity to swap in an internal IP after the check passes.
Do I still need to block redirects if I pin the IP?
Yes. A pinned first request can still receive a 302 pointing at an internal host, and if the client follows redirects automatically it resolves and connects to the new location without your validation running again. Disable automatic redirect following and re-run the full resolve-validate-pin routine on each hop you choose to follow.
Related
- Server-Side Request Forgery (SSRF) Prevention — the parent guide covering the full SSRF threat class
- SSRF allowlists and metadata service protection — building the allowlist and shielding cloud metadata endpoints
- Defining trust boundaries — why the fetcher and internal services belong to different trust zones