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.

DNS Rebinding TOCTOU Timeline Left to right timeline. Step one: validation resolves the attacker domain to a public IP and the private-range check passes. Step two: because the DNS TTL is zero, the HTTP client re-resolves the same name at connect time and receives 169.254.169.254, connecting to the internal metadata service. time 1. Validation lookup attacker.example resolves to 203.0.113.10 (public) Check PASSES not in private range 2. Connect re-resolves TTL 0, same name now 169.254.169.254 Metadata service hit no second validation
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.