What happened
Django 5.2.4 fixes a flaw in URLValidator: the validator accepts several alternate IP encodings that most hand-written allowlist checks never consider. The result is a URL that looks harmless, passes your check, and then points at your internal network.
The bug is not that Django "allows SSRF" — URLValidator was never an SSRF control. The problem is that a great many codebases use it as though it were.
If your application accepts a user-supplied URL and fetches it, keep reading even if you do not use Django. The same class of bug lives in .NET's Uri, Node's URL and Go's net/url.
The mechanism
All four strings below resolve to loopback, and a string-comparison check misses every one:
| String | Resolves to | Why it slips through |
|---|---|---|
http://127.1/ | 127.0.0.1 | A valid shorthand IPv4 form |
http://2130706433/ | 127.0.0.1 | IPv4 as a 32-bit integer |
http://017700000001/ | 127.0.0.1 | Octal notation |
http://[::1]/ | ::1 | IPv6 loopback; an IPv4 blocklist never touches it |
There is a further variant that has nothing to do with notation and everything to do with timing:
1. Validator resolves evil.test → 93.184.216.34 (public, fine) → allowed2. HTTP library resolves AGAIN → 127.0.0.1 (DNS changed, TTL 0)3. The real request lands on loopbackThat is DNS rebinding, and it is why validating the hostname before the call is never enough.
What to do
If you use Django
Upgrade to >= 5.2.4:
pip index versions djangopip install --upgrade "django>=5.2.4"python -c "import django; print(django.get_version())"If you cannot upgrade yet
Block at the connection layer, not the string layer:
import ipaddress, socketfrom urllib.parse import urlparse ALLOWED_HOSTS = {"api.partner.example", "cdn.partner.example"} def safe_target(raw: str) -> tuple[str, int]: u = urlparse(raw) if u.scheme != "https" or u.hostname not in ALLOWED_HOSTS: raise ValueError("destination not allowed") # Resolve ONCE, then connect to the exact IP you checked. infos = socket.getaddrinfo(u.hostname, u.port or 443, proto=socket.IPPROTO_TCP) ip = ipaddress.ip_address(infos[0][4][0]) if not ip.is_global: raise ValueError(f"blocked destination {ip}") return str(ip), u.port or 443Do not reach for a blocklist instead. 127.0.0.1, localhost, 0.0.0.0, [::1], 2130706433, and any domain you do not control that points at loopback — the deny list is always missing one entry.
The layer that does not depend on your code
Egress deny-by-default at the network layer. Outbound-calling services run in their own network with no view of the metadata endpoint or internal services. With that in place, an SSRF that slips past the code becomes a log line rather than an incident.
Verifying the fix
The suite must fail on the old code and pass after the patch — otherwise it proves nothing:
import pytest @pytest.mark.parametrize("url", [ "http://169.254.169.254/latest/meta-data/", "http://127.1/", "http://2130706433/", "http://[::1]/", "https://rebind.test/", # DNS returns 127.0.0.1])def test_preview_rejects_internal(client, url): assert client.post("/preview", json={"url": url}).status_code == 400These tests do not catch SSRF over other schemes if you allow more than http/https, nor the case where the HTTP library re-resolves — to stop that you must pin the IP as shown above.
Back to the fundamentals
This class of bug is API7 (SSRF) in the API Security Top 10 and C10 in the Proactive Controls. If that ground is not solid for you yet, session W1D5 in the path walks all four variants above and ends with the patch.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…