Why SSRF blocklists keep failing
Most SSRF fixes I read during code review are blocklists. The function parses a URL, checks the host against a list of things that look internal, and returns the fetch if nothing matched. It looks careful. It is almost always bypassable, and the reason is structural rather than a matter of the list being short.
The shape of the mistake #
Here's the pattern, reduced to its essentials:
BLOCKED = {"localhost", "127.0.0.1", "169.254.169.254"}
def fetch(url: str) -> bytes:
host = urlparse(url).hostname
if host in BLOCKED:
raise ValueError("blocked host")
return requests.get(url).contentThe check operates on the string the user supplied. The request operates on whatever that string eventually resolves to. Everything that goes wrong with SSRF filtering lives in the gap between those two things.
Four ways the gap gets exploited #
Alternate encodings of the same address. 127.0.0.1 is one spelling. 127.1, 2130706433, and 0177.0.0.1 are others, and the resolver accepts all of them. A blocklist enumerates spellings; the network layer only cares about the resulting address.
Names you don't control. A hostname on an attacker's domain can resolve to a loopback or link-local address. The string looks like an ordinary external host because it is one — the value that matters only appears at resolution time.
Redirects. The allowed host returns 302 to an internal one. If the filter checks the original URL and the HTTP client follows redirects by default (most do), the check inspected a URL the client never actually settled on.
Resolution timing. Even a filter that resolves the name itself can be beaten if the name resolves again before the request is made, returning a different answer the second time. Validating and then fetching by hostname leaves that window open.
What actually closes it #
Invert the question. Instead of asking whether this destination is one of the bad ones, ask whether it is one of the handful you meant to allow.
ALLOWED_HOSTS = {"images.partner-cdn.example"}
def fetch(url: str) -> bytes:
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValueError("scheme not permitted")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("host not permitted")
# Resolve once, verify the address, then connect to that address.
addr = resolve_public_address(parsed.hostname)
return get_pinned(url, addr, allow_redirects=False)Three properties are doing the work, and dropping any one of them reopens the hole:
- The allowlist is of hosts you intended, so unfamiliar spellings fail by default rather than needing to be anticipated.
- The address is resolved once and the connection is pinned to it, which removes the re-resolution window.
- Redirects are disabled — or, if the integration genuinely needs them, each hop is validated by the same function before it is followed.
When you can't have an allowlist #
Sometimes the feature is "fetch the URL the user gives you," and an allowlist would defeat the point. Webhook testers and link previewers live here. In that case the control moves down the stack: send the request from a network segment with no route to internal ranges and no instance metadata endpoint, through an egress proxy that enforces the destination policy. Application-layer parsing is not the enforcement point, because the application layer never sees the address that gets dialled.
Testing it #
In a lab, stand up a service with the vulnerable pattern and confirm each bypass class works before confirming the fix blocks it. Testing only the obvious http://127.0.0.1/ case is how a filter passes review and fails in production — that literal is the one string every blocklist gets right.
A useful check when reviewing someone else's fix: find the line that resolves the name and the line that opens the connection. If the validation isn't between them, operating on the same address the connection uses, the filter is decorative.