On this page
You added a destination check to the URL fetcher, so it only talks to hosts you trust.
Good. Then one of those trusted hosts answers 302 to an address you would never allow,
your client follows it without asking, and the request you were guarding lands exactly
where you were guarding against.
The guard that only sees hop zero
Most allowlists look like this:
import requests
from urllib.parse import urlparse
def fetch(url):
if urlparse(url).hostname not in ALLOWED_HOSTS:
raise ValueError("host not allowed")
return requests.get(url, timeout=5) # allow_redirects=True by default
The check runs once, on the URL the client handed you. Whether the client then follows
redirects depends on the client. These follow redirects by default: requests (every helper
except head), urllib.request, aiohttp and Go’s net/http. These do not:
requests.head, httpx (since 0.20) and curl without -L. Check yours. Where it does
follow, the Location is chosen by the server that answered, not by you, so the request that
actually leaves the box is not the URL you validated. Trusting hop zero says nothing about
hop one.
Two ways that becomes a bug:
- An allowed host with an open redirect. A trusted domain that has an open redirect
will bounce you to an arbitrary URL if asked the right way. Your allowlist said yes to
the domain; the domain said
302 Location: http://169.254.169.254/. - A name that points inward. You checked the hostname string, but DNS resolves it to a loopback or private address — or resolves twice, once for your check and once for the connect, to different answers. The string passed; the socket went elsewhere.
Validate what you connect to, not what you were told
Check the destination on every hop, the first one included, and follow redirects yourself:
import requests
from urllib.parse import urljoin
MAX_HOPS = 5
def fetch(url):
for _ in range(MAX_HOPS + 1):
check_destination(url) # hop zero and every hop after
resp = requests.get(url, allow_redirects=False, timeout=5)
if not resp.is_redirect:
return resp
url = urljoin(resp.url, resp.headers["Location"]) # Location may be relative
raise ValueError("too many redirects") # fail closed past the cap
The first URL goes through the same check as every redirect target. A relative Location
is resolved against the URL that returned it before it is checked. After five redirects
the loop gives up instead of returning whatever it has.
check_destination is where the work is. The allowlist says which names you trust; the
address check says what those names may resolve to, and it should allow only globally
routable addresses rather than subtract a few bad ones:
import ipaddress
import socket
from urllib.parse import urlparse
NAT64 = ipaddress.ip_network("64:ff9b::/96")
GLOBAL_V6 = ipaddress.ip_network("2000::/3")
# Not global, or an IPv4 address in disguise. Some Python releases call these global.
REJECT = [ipaddress.ip_network(n) for n in (
"192.0.0.0/24", # IETF protocol assignments
"192.88.99.0/24", # 6to4 relay anycast
"2001::/23", # IETF protocol assignments, including Teredo (2001::/32)
"2002::/16", # 6to4
"3fff::/20", # documentation
)]
def public(addr):
ip = ipaddress.ip_address(addr)
if ip.version == 6:
if ip.ipv4_mapped is not None: # ::ffff:127.0.0.1
ip = ip.ipv4_mapped
elif ip in NAT64: # 64:ff9b::7f00:1
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
elif ip not in GLOBAL_V6: # ::1, fc00::/7, fe80::/10, ...
return False
return ip.is_global and not ip.is_multicast and not any(ip in net for net in REJECT)
def check_destination(url):
# Before anything else: refuse what a second URL parser could read differently.
if any(c == "\\" or c.isspace() or not c.isprintable() for c in url):
raise ValueError("destination not allowed")
parts = urlparse(url)
if parts.username is not None or parts.password is not None or "@" in parts.netloc:
raise ValueError("destination not allowed") # no userinfo, even an empty one
if parts.scheme not in ("http", "https") or parts.hostname not in ALLOWED_HOSTS:
raise ValueError("destination not allowed")
port = parts.port or (443 if parts.scheme == "https" else 80)
infos = socket.getaddrinfo(parts.hostname, port, type=socket.SOCK_STREAM)
if not all(public(info[4][0]) for info in infos):
raise ValueError("destination resolves to a non-public address")
return [info[4][0] for info in infos]
- Refuse URLs that two parsers could read differently.
urlparsereadshttp://evil.test:8080\@allowed.test/as hostallowed.test, because it counts everything before the@as userinfo. urllib3 2.6, whichrequestsuses, ends the authority at the backslash and connects toevil.test. Socheck_destinationfirst refuses a backslash, whitespace, control characters and any userinfo. Therequestsloop is only as safe as the agreement between the two URL parsers;fetch_pinnedbelow avoids that gap because it connects to the host and port it parsed itself, and no second parser sees the URL. - Check every address, not the string.
getaddrinfocan return several, and one inward address is enough to refuse. In this code the allowlist runs before resolution, so a numeric spelling of the loopback address never reachesgetaddrinfo. The spellings matter if you use a deny-list instead, or accept raw IP input: a resolver can read2130706433and0x7f.1as127.0.0.1, and parsers disagree about0177.0.0.1:inet_aton-style parsers read it as octal127.0.0.1, while older releases of Python’sipaddressand Go’snet.ParseIPread it as decimal177.0.0.1(CVE-2021-29921, CVE-2021-29923). Judge the resolved addresses, not the string. - Unwrap embedded IPv4 first.
::ffff:127.0.0.1(IPv4-mapped) and64:ff9b::7f00:1(NAT64) carry an IPv4 address inside an IPv6 one. The NAT64 prefix is registered as globally reachable, sois_globalalone lets the IPv6 form through; judge the IPv4 address inside it. 6to4 (2002::/16) and Teredo (2001::/32) embed IPv4 addresses too and are rejected outright. - Everything not global is out. That covers
0.0.0.0/8, carrier-grade NAT space (RFC 6598), loopback, link-local, RFC 1918,::1,fc00::/7andfe80::/10. Python’sis_globalfollows the IANA special-purpose registries from Python 3.13 (gh-113171), and from the patch releases 3.12.4, 3.11.10, 3.10.15 and 3.9.20 (CVE-2024-4032); earlier releases get some ranges wrong, which is whyREJECTlists them explicitly and the check gives the same answers on 3.9 and later.is_globalis also true for multicast addresses, so the check excludes those, and the2000::/3test keeps IPv6 to global unicast, which also leaves out local-use NAT64 (64:ff9b:1::/48).
One gap is left. You resolve to check, then requests resolves again to connect, so a name
that answers differently the second time (DNS rebinding) slips past. Close it by connecting
to the address you checked, while keeping the name for the Host header, the TLS server
name and the certificate check. With the standard library, one pinned hop is fetch_pinned,
and the loop calls it instead of requests.get. It reuses MAX_HOPS and
check_destination from above:
import http.client
import socket
import ssl
from urllib.parse import urljoin, urlsplit
MAX_BYTES = 1_000_000
class Pinned:
"""Dial pinned_ip; keep the name for the Host header.
For HTTPS the name is also used for SNI and the certificate check.
"""
def __init__(self, host, pinned_ip, **kw):
super().__init__(host, **kw)
self.pinned_ip = pinned_ip
def dial(self):
return socket.create_connection((self.pinned_ip, self.port), self.timeout)
class PinnedHTTP(Pinned, http.client.HTTPConnection):
def connect(self):
self.sock = self.dial()
class PinnedHTTPS(Pinned, http.client.HTTPSConnection):
def connect(self):
ctx = ssl.create_default_context()
self.sock = ctx.wrap_socket(self.dial(), server_hostname=self.host)
def fetch_pinned(url):
"""One GET, no redirects followed. Returns (status, Location or None, body)."""
parts = urlsplit(url)
ip = check_destination(url)[0] # raises unless every address is public
conn_class = PinnedHTTPS if parts.scheme == "https" else PinnedHTTP
conn = conn_class(parts.hostname, ip, port=parts.port, timeout=5)
try:
conn.request("GET", (parts.path or "/") + ("?" + parts.query if parts.query else ""))
resp = conn.getresponse()
body = resp.read(MAX_BYTES + 1) # one byte over the cap is enough to refuse
if len(body) > MAX_BYTES:
raise ValueError("response too large")
return resp.status, resp.getheader("Location"), body
finally:
conn.close()
def fetch(url):
for _ in range(MAX_HOPS + 1):
# fetch_pinned checks the destination, then dials that address.
status, location, body = fetch_pinned(url)
if status not in (301, 302, 303, 307, 308):
return status, body
if not location:
raise ValueError("redirect without a Location")
url = urljoin(url, location) # Location may be relative
raise ValueError("too many redirects")
fetch_pinned runs the destination check itself and dials the first address it approved,
so a name is never resolved twice. It uses only that first address and does not try the
others, so a name whose first address is down fails instead of falling back. It reads at
most MAX_BYTES + 1 bytes and refuses a larger body. timeout=5 limits each socket
operation, not the whole fetch, so a server that keeps sending slowly can still hold the
call open. Nothing in http.client follows redirects, so the loop is the only place a hop
can happen. A pin only covers connections you open: this code ignores HTTP_PROXY and
HTTPS_PROXY, but a client that honours them, such as requests, hands the request to the
proxy, the proxy resolves the name, and your pin is bypassed. Enforce the rule at the proxy
in that case. Either way, default egress to deny, so a destination has to be chosen on
purpose.
How others solve it
The durable pattern is a small resolver layer under the HTTP client: resolve, check the address against what is globally routable, pin it, repeat on every redirect. Some stacks let you disable redirects and hand you each hop to approve. The common move is the same — the decision shifts from the hostname you were given to the address you are about to open.
Same thread as the rest of this trio: the gate finds the endpoint, and the default decides the bug. Here the default can be “follow redirects,” and a check that fires once, on hop zero, is a check the network routes around.
Updated 2026-10-18: The fixed loop now checks the first URL as well as every redirect
target, resolves relative redirects and caps the hops. Added a check that refuses URLs two
parsers can read as different hosts, replaced the deny-list advice with an allowlist of
globally routable addresses that unwraps IPv4-mapped and NAT64 forms, and added a pinned
connection with a complete fetch_pinned, a DNS rebinding fix and a proxy caveat.
Corrected which clients follow redirects by default, and removed unsourced claims about how
many clients and domains behave this way. Every change is in the
file history.