Closing the SSRF DNS-Rebinding Hole with a Custom Resolver

We fetch user-supplied URLs on the server: link previews, webhook delivery, safety scans. Every one of those is a potential SSRF (Server-Side Request Forgery) — a way to make our server hit our internal network.

The obvious defense is: resolve the hostname, check the IP isn't private, then fetch. We had exactly that. It has a hole big enough to drive an attacker through.

TL;DR: Validating the resolved IP before the request is a TOCTOU (time-of-check to time-of-use) bug. The hostname re-resolves at connect time, and an attacker controlling DNS can return a public IP for the check and 169.254.169.254 for the actual connection. The fix: validate inside the HTTP client's own DnsResolver, so the IP you checked is the IP you connect to.


The Problem

The naive pattern:

InetAddress ip = InetAddress.getByName(host); // check
if (isPrivate(ip)) throw new SecurityException();
httpClient.execute(new HttpGet(url));         // use — re-resolves host!

Between the check and the use, the hostname is resolved again by the HTTP client. An attacker who controls the authoritative DNS for evil.example.com sets a very short TTL and alternates answers: a harmless public IP when we validate, a private/link-local IP when Apache HttpClient opens the socket. This is DNS rebinding. Our check passed; our connection went to the metadata endpoint.

The Fix

Move the validation into the resolver the client actually uses. Apache HttpClient 5 lets you inject a DnsResolver into the connection manager, so the same validated addresses are the ones dialed:

public class SsrfSafeDnsResolver implements DnsResolver {
    @Override
    public InetAddress[] resolve(String hostname) throws UnknownHostException {
        InetAddress[] addresses = InetAddress.getAllByName(hostname);
        for (InetAddress addr : addresses) {
            if (isPrivateOrReservedIp(addr)) {
                log.warn("SSRF blocked - DNS rebinding attempt: {} -> {}",
                        hostname, addr.getHostAddress());
                throw new UnknownHostException("SSRF blocked: private IP");
            }
        }
        return addresses;
    }
}

There's no separate check-then-use anymore. The resolver either returns only safe addresses or throws — and those returned addresses are what the socket connects to. The rebinding window is gone.

Get the IP Ranges Right

"Is this IP private" is deceptively large. Loopback and RFC 1918 are the ones everyone remembers; the ones that bite you are the rest. Our check covers all of them:

public static boolean isPrivateOrReservedIp(InetAddress addr) {
    if (addr.isLoopbackAddress()      // 127.0.0.0/8, ::1
            || addr.isLinkLocalAddress()   // 169.254.0.0/16, fe80::/10
            || addr.isSiteLocalAddress()   // 10/8, 172.16/12, 192.168/16
            || addr.isAnyLocalAddress()    // 0.0.0.0, ::
            || addr.isMulticastAddress()) {
        return true;
    }
    byte[] bytes = addr.getAddress();
    if (bytes.length == 4) {
        int first = bytes[0] & 0xFF, second = bytes[1] & 0xFF;
        if (first == 100 && second >= 64 && second <= 127) return true; // 100.64/10 CGNAT
        if (first == 169 && second == 254) return true;                 // link-local (metadata!)
        // ... TEST-NET, benchmark, 240/4 reserved, etc.
    }
    // IPv6: fc00::/7 ULA, and IPv4-mapped IPv6 unwrapped and re-checked
    return false;
}

Two things people miss: 169.254.169.254 (the cloud metadata endpoint) lives in link-local space, and IPv4-mapped IPv6 addresses like ::ffff:10.0.0.1 sneak a private IPv4 past an IPv6-only check. We unwrap the mapped address and re-run the same check on the embedded IPv4.

One Policy, Every Egress Path

The subtle failure mode is having several outbound clients and only hardening one. A GET fetch for a link preview and a POST for webhook delivery are different code paths, and it's easy to guard the one you were thinking about while the other quietly stays open. The fix is to make the guarded resolver the only way to build an HTTP client in your app — wire it into a shared client factory (or the framework bean everything injects), so a new outbound call site inherits the policy for free instead of re-implementing it (or forgetting to). One place to audit, one place to fix, no drift between call sites.

What This Doesn't Cover

Be honest about the boundary. Validating resolved IPs stops the classic SSRF and DNS-rebinding cases, but it isn't the whole story. Redirects can bounce you to a new host mid-request — so either disable redirect-following on these clients or re-validate every hop. And if the request carries anything sensitive (internal headers, auth tokens), a permitted public destination can still be an exfiltration target; IP validation says "not internal," not "trustworthy." Know which of those your threat model needs, and layer accordingly.

Lessons Learned

  • Check-then-fetch is a TOCTOU bug. If the thing that validates the IP isn't the thing that opens the socket, they can disagree. Validate inside the resolver.
  • "Private IP" is a long list. Loopback and RFC 1918 aren't enough — link-local (cloud metadata), CGNAT, IPv4-mapped IPv6, and reserved ranges all matter.
  • Centralize egress. Every server-initiated, user-influenced request should route through one guarded client. Two clients means two policies means one of them is wrong.

How do you guard outbound requests in your services? Drop a comment.

Building jo4.io - a URL shortener with analytics for developers.