A passed check is not proof: the SSRF filter that resolves DNS twice
The SSRF filter passed: the URL resolved to a public IP, not 169.254.169.254, so it was waved through. But a guard that resolves the hostname to check it and then hands that same hostname to an HTTP client has inspected one DNS lookup and connected on another — and an attacker who controls the name can make the two disagree. A practitioner's guide to the validate-then-connect gap behind a wave of 2026 SSRF-filter bypasses, why following a redirect skips the check entirely, and how to build a client that resolves once, pins the IP it validated, and actually holds.
By The Xalgorix Team
The report says the filter held. The user-supplied URL was parsed, its hostname was resolved, the resulting IP was checked against the usual blocklist — loopback, the RFC 1918 private ranges, link-local, the cloud metadata address at 169.254.169.254 — and it came back clean, an ordinary public address, so the request was allowed. That is already a better guard than the string-matching blocklists a great deal of software still ships: it resolves the name and inspects the real destination instead of pattern-matching the URL. And it is still bypassable, because checking an address and connecting to one are two separate events, split apart in time, and nothing in that filter forces them to agree.
Through 2026 this exact shape has been disclosed again and again, often in the HTTP-fetching features of AI and developer tooling: a validator resolves a hostname, approves the IP it sees, then passes the hostname to an HTTP client that resolves it a second time at connection. An attacker who controls the name answers the first lookup with a public IP and the second with 169.254.169.254. The check is real; it is just looking at a different packet than the one that leaves the box. This is a build-the-client piece: what the gap is, the two ways it opens, and how to close it so a passed check actually means a safe connection.
What a reachable internal request is worth
Server-side request forgery (SSRF, CWE-918) matters because the server is a better-placed client than the attacker. It sits inside the perimeter, so a request it can be tricked into making reaches internal services, admin panels, and — the prize — the cloud instance metadata endpoint at 169.254.169.254, shared by AWS, GCP and Azure. On an EC2 instance still serving the legacy IMDSv1, two unauthenticated GETs — one to /latest/meta-data/iam/security-credentials/ to read the attached role name, then one to /latest/meta-data/iam/security-credentials/<role-name> — hand back that role's temporary credentials. The destination is the whole game: an SSRF that can only reach the public internet is noise; one that can reach the metadata service is a credential-theft primitive. Which is exactly why the filter in the intro exists — and exactly why getting it slightly wrong is expensive.
The filter that looks right
Here is the guard, in the form it usually takes. It does the thing the naive version forgets: it resolves the host and judges the actual IP, not the URL string.
# Looks safe: it resolves the host and inspects the real IP,
# not just the URL text. The bug is the last line.
def fetch(user_url):
host = urlparse(user_url).hostname
ip = socket.gethostbyname(host) # DNS lookup #1 -> the CHECK
if is_blocked(ip): # loopback, 10/8, 172.16/12,
raise Unsafe("blocked destination") # 192.168/16, 169.254.0.0/16
return requests.get(user_url) # DNS lookup #2 -> the CONNECTRead the last two lines together. The decision is made about ip, the answer to lookup #1. The request is made against user_url — a string that still contains the hostname — and requests.get does its own, independent DNS resolution to open the socket. There are two lookups, and the code never checks that the second returned the address the first one approved. The OWASP cheat sheet names this precisely: “A second, unchecked DNS lookup between validation and connection can bypass these checks,” and “Domain allowlisting alone does not prevent DNS rebinding.”
Why it resolves DNS twice
The attacker's lever is that they own the authoritative DNS for the hostname they submit, and DNS answers carry a time-to-live the resolver is free to honour. Serve the record with TTL=0 and every lookup is a fresh question to the attacker's server, which is free to give a different answer each time. The first query — the validator's — returns a public IP it controls and the check passes. Milliseconds later the HTTP client asks the same question to open its socket, and this time the answer is 169.254.169.254. This is DNS rebinding, and against a validate-then-connect filter it is a time-of-check to time-of-use race (CWE-367) that the attacker wins almost for free, because the two lookups are an ordinary part of the request's own control flow.
This is not a hypothetical. In CVE-2026-53708 (IBM's mcp-context-forge gateway, fixed in 1.0.3, CVSS 6.6), the validator blocks private, loopback, link-local and metadata addresses — and then passes the original hostname to the HTTP client, which, in the advisory's words, “independently re-resolves DNS at connection time with no IP binding between the two operations.” The source even carried a docstring and a TODO acknowledging the limitation. Craft CMS shipped the identical pattern: in CVE-2026-27127 (fixed in 5.8.23 and 4.16.19) the asset-upload path resolves the host with gethostbyname(), checks it against a metadata blocklist, then fetches through Guzzle, which resolves again — “Two separate DNS lookups occur,” as the advisory puts it. Tellingly, that advisory is a bypass of an earlier fix (CVE-2025-68437): the first patch added the IP check but still connected by name.
The redirect that skips the check entirely
Pinning the DNS answer closes the rebinding window, but there is a second door that has nothing to do with DNS: redirects. A validator almost always inspects the URL it is handed. If the HTTP client then follows a 302 on its own, the destination the client actually reaches was never submitted to the validator at all.
Hop 1 GET https://preview.attacker.example/card
validator resolves -> 203.0.113.10, public, allowed. Fetch it.
Hop 2 302 Location: http://169.254.169.254/latest/meta-data/iam/
client follows automatically -> request to IMDS, never re-checked.CVE-2026-43884 in WWBN/AVideo (affecting 14.3 and earlier, CVSS 7.7) is the textbook case, and it fails both ways at once. Two endpoints validate the URL with isSSRFSafeURL() and then fetch it with bare file_get_contents() — which, the advisory notes, follows redirects automatically; “Since isSSRFSafeURL() only validates the initial URL, the redirect target bypasses all SSRF protections.” And the same function exposes a $resolvedIP output parameter precisely so a caller can pin the validated address — but eight of its nine callers discard it, re-resolving at fetch time and reopening the rebinding gap. The advice here is blunt and comes straight from OWASP: “Disable the support for the following of the redirection in your web client.” If you must follow a redirect, treat each hop as a brand-new, untrusted URL and run the whole validation again.
A 2026 pattern, not a 2026 bug
What makes this worth an article rather than a changelog line is how reliably the same mistake reappears, especially in the URL-fetching and link-preview features that AI and MCP tooling have made ubiquitous. Beyond the cases above, the mcp-atlassian server was found to be rebindable even after a prior SSRF fix, because that fix “returns a string verdict, not a pinned IP,” and the fetcher “re-resolves the raw hostname at connect time.” It is worth being honest about the strength of that particular report: its author describes the proof-of-concept as a “boundary demonstration” rather than a live, end-to-end SSRF, and winning the race in the wild still requires a DNS responder that beats the resolve-to-connect window. The pattern, though, is unmistakable — a validator and a fetcher that each resolve DNS on their own are not one control but two uncoordinated ones, and the attacker only has to split them.
Building a client that holds
The fix is one idea with a few corollaries: resolve once, validate what you resolved, and connect to that exact address — never hand the name back to the client to look up again. OWASP states it directly: use an HTTP client that “connects to the validated IP while preserving the original hostname” for the Host header, SNI and certificate verification. Expressed as logic:
def safe_fetch(user_url):
parts = urlparse(user_url)
if parts.scheme not in ("http", "https"):
raise Unsafe("scheme")
host, port = parts.hostname, parts.port or default_port(parts.scheme)
# Resolve ONCE. Validate EVERY answer (a rebinder can return a mix).
addrs = {ai[4][0] for ai in socket.getaddrinfo(host, port)}
if not addrs or any(is_blocked(ip) for ip in addrs):
raise Unsafe("blocked destination")
pinned = choose(addrs)
# Open the socket to the IP you validated; keep the hostname for
# the Host header, TLS SNI, and cert check. This needs a custom
# resolver/transport, NOT just swapping the IP into the URL --
# rewriting the URL to the IP breaks certificate verification.
return http_get(ip=pinned, host=host, port=port,
follow_redirects=False)The corollaries matter as much as the core:
- Validate every record, not the first. A rebinder can return several A/AAAA answers; reject if any of them is blocked, and pin the connection to a specific validated address so a later re-resolution can't substitute one.
- Turn off automatic redirects. If you must follow them, re-run resolve-and-validate-and-pin on every hop — the
Locationheader is attacker-controlled input, not a trusted continuation. - Apply the policy to retries and fallbacks too. OWASP is explicit that the destination check must cover “retries and fallback connections as well” — a connection pool or a retry that re-resolves is another second lookup.
- Prefer an allowlist where you can. When the set of legal destinations is known, match the host against an allowlist and build the request yourself; OWASP's first principle is that “Deny-lists are bypass-prone. Prefer allow-lists.” Reserve the IP-blocklist-and-pin approach for the genuinely open-destination case (a webhook, a link preview), and remember to block IPv6 loopback and unique/link-local ranges, not just IPv4.
curl --resolve preview.example:443:203.0.113.10 https://preview.example/ dials the IP you name while still sending Host: preview.example and verifying the certificate against that hostname. That is the exact behaviour your client needs: the name is preserved for TLS and routing, but DNS is decided once, by you.Network backstops, and their honest limits
Application-layer pinning is the fix; the network is the net under it. On AWS, enforcing IMDSv2 and disabling IMDSv1 blunts the worst outcome: IMDSv2 is session-oriented, so a caller must first issue a PUT to /latest/api/token and then send that token on every GET. A classic SSRF that can only coerce a blind GET cannot mint the token, the PUT is rejected outright if it carries an X-Forwarded-For header (the reverse-proxy case), and the token response defaults to an IP hop limit of 1 so it cannot be relayed off-box. An outbound egress allowlist that simply has no route to 169.254.169.254 or RFC 1918 space is the other backstop.
Be honest about what these do and don't buy. IMDSv2 protects one endpoint on one cloud; it does nothing for an SSRF aimed at an internal admin service, a database, another cloud's metadata address, or a partner on your network — and plenty of environments still run IMDSv1 for backwards compatibility. Network egress controls are strong but coarse, and frequently relaxed for the very service that needs to fetch arbitrary user URLs. They are defense in depth, not a substitute for a client that connects where it checked.
Proof, not a passing test
The thread running through every advisory above is the one Xalgorix is built around: a control that looks correct is not the same as a control that holds, and the only way to tell them apart is to try to break it under the conditions an attacker would use. A filter that returns 403 for http://169.254.169.254/ has demonstrated nothing about rebinding; the test that matters points a TTL=0 hostname whose first answer is a public IP and whose next would be an internal one, and asserts that a correct resolve-and-pin client connects only to the public address it validated — it never makes the second lookup that would reach the internal one — alongside a separate test that the client refuses outright when the first resolution is already a blocked address. Repeat the rebinding case to account for the race. That is also the honest limit of any automated check, this one included: proving the gap is closed on your fetch path is a statement about your code, not a guarantee about every library, proxy and retry layer between it and the socket. Resolve once, pin what you validated, don't follow redirects blindly — and then prove the client connects where it looked, rather than trusting that it does.
Ready to see it prove a bug?
Start scanning on Cloud →