Attach the policy to the connection, not the URL string. Allow only http/https and ports 80/443 and reject credentials; resolve the host (or read an IP literal in any notation) and reject loopback, private, link-local and IPv4-mapped addresses with ipaddress; connect to the vetted IP; re-validate every redirect; cap time and bytes.
- Find every server-side fetch built from request data in your views, management commands and Celery tasks: requests, httpx, urllib.request and http.client.
- Parse the URL once with urllib.parse.urlsplit. Enforce a scheme allowlist (http/https) and a port allowlist (80/443) and reject userinfo. Django's URLValidator only checks the syntax, not the destination.
- Resolve the host with socket.getaddrinfo, or recognize an IP literal in any notation, and classify each address with the ipaddress module; reject anything that is not public, unwrapping IPv4-mapped and NAT64 first. Reject the whole set if any answer is internal.
- Connect to the vetted IP and keep the original host for the Host header, so the client never re-resolves (no DNS rebinding); with requests or httpx that needs a custom adapter or transport, not a check before get(url).
- Handle redirects yourself: turn automatic redirects off (requests follows them by default) and run the full policy on each hop.
- Set a timeout and read the body in bounded chunks with a byte cap; return a generic 400 or 502 and log the destination and reason server-side.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def _target(raw_url):
"""No validation at all: parse the URL and hand the host to the resolver."""
parts = urlsplit(raw_url)
host_header = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}"
path = (parts.path or "/") + (("?" + parts.query) if parts.query else "")
return parts.hostname, (parts.port or default_port(parts.scheme)), host_header, path, f"{parts.scheme}://{parts.netloc}"With IMPL=vulnerable, 12 of 15 tests fail (10 exploit tests).
The fix: validate the destination
# Private, loopback, link-local and other non-public ranges in one table, so the policy
# is reviewable in one place.
_DENY_V4 = [ipaddress.ip_network(c) for c in (
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16",
"198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
)]
_V6_GLOBAL_UNICAST = ipaddress.ip_network("2000::/3")
_V6_DOC = ipaddress.ip_network("2001:db8::/32")
_NAT64 = ipaddress.ip_network("64:ff9b::/96")
def parse_ip_literal(host):
"""inet_aton-style parse so decimal (2130706433), octal (0177.0.0.1) and hex
(0x7f.0.0.1) literals are recognised as addresses rather than trusted as names."""
inner = host[1:-1] if host.startswith("[") and host.endswith("]") else host
try:
return str(ipaddress.IPv6Address(inner))
except ipaddress.AddressValueError:
pass
if inner != host:
return None # bracketed but not IPv6
parts = host.split(".")
if not 1 <= len(parts) <= 4:
return None
nums = []
for part in parts:
if re.fullmatch(r"0[xX][0-9a-fA-F]+", part):
nums.append(int(part, 16))
elif re.fullmatch(r"0[0-7]*", part):
nums.append(int(part, 8))
elif re.fullmatch(r"[1-9][0-9]*", part):
nums.append(int(part))
else:
return None
caps = [0xFFFFFFFF, 0xFFFFFF, 0xFFFF, 0xFF]
if nums[-1] > caps[len(nums) - 1] or any(n > 0xFF for n in nums[:-1]):
return None
shifts = {1: [0], 2: [24, 0], 3: [24, 16, 0], 4: [24, 16, 8, 0]}[len(nums)]
value = sum(n << s for n, s in zip(nums, shifts))
return str(ipaddress.IPv4Address(value))
def assert_public_address(ip_str):
ip = ipaddress.ip_address(ip_str)
if isinstance(ip, ipaddress.IPv6Address):
if ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped # ::ffff:a.b.c.d
elif ip in _NAT64:
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) # 64:ff9b::a.b.c.d
if isinstance(ip, ipaddress.IPv6Address):
if ip not in _V6_GLOBAL_UNICAST or ip in _V6_DOC:
raise BlockedRequestError("non-public IPv6 address")
return
if any(ip in net for net in _DENY_V4):
raise BlockedRequestError("non-public IPv4 address")
def vet(raw_url, resolve):
"""Parse the URL, enforce the scheme and port allowlists, turn the host into a
vetted address, and reject (not resolve) IP literals that are not public."""
parts = urlsplit(raw_url)
if parts.scheme not in ALLOWED_SCHEMES:
raise BlockedRequestError("scheme not allowed")
if parts.username or parts.password:
raise BlockedRequestError("credentials in URL not allowed")
if not parts.hostname:
raise BlockedRequestError("missing host")
try:
port = parts.port or default_port(parts.scheme)
except ValueError:
raise BlockedRequestError("invalid port")
if port not in ALLOWED_PORTS:
raise BlockedRequestError("port not allowed")
literal = parse_ip_literal(parts.hostname)
addresses = [literal] if literal else resolve(parts.hostname)
if not addresses:
raise BlockedRequestError("host did not resolve")
for ip in addresses:
assert_public_address(ip) # reject the whole set if any is internal
host_header = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}"
path = parts.path or "/"
if parts.query:
path += "?" + parts.query
return addresses[0], port, host_header, path, f"{parts.scheme}://{parts.netloc}"With IMPL=fixed, 15 of 15 tests pass.
The fix: re-validate every redirect
def fetch_url(raw_url, deps):
"""A 3xx response is a fresh destination decision, not permission to follow. Re-run
the full policy on every hop and pin each connection to its own vetted address."""
resolve = deps["resolve"]
max_redirects = deps.get("max_redirects", MAX_REDIRECTS)
target = raw_url
for _ in range(max_redirects + 1):
ip, port, host_header, path, _origin = vet(target, resolve)
result = send_once(ip, port, host_header, path, deps["dial"],
max_bytes=deps["max_bytes"], timeout=deps["timeout"])
if 300 <= result["status"] < 400 and result["location"]:
target = urljoin(target, result["location"]) # relative to this hop's URL; validated again at the top
continue
return summarise(result)
raise BlockedRequestError("too many redirects")With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
# A substring blocklist of "obviously internal" hosts. Looks reassuring; the address an
# attacker actually reaches is decided by DNS, which this never consults.
_BLOCKED_SUBSTRINGS = (
"localhost", "127.0.0.1", "0.0.0.0", "169.254", "10.", "192.168", "172.16", "::1", "metadata", "internal",
)
def _screen(hostname):
host = (hostname or "").lower()
if any(bad in host for bad in _BLOCKED_SUBSTRINGS):
raise BlockedRequestError("host is blocked")
def _target(raw_url):
parts = urlsplit(raw_url)
host_header = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}"
path = (parts.path or "/") + (("?" + parts.query) if parts.query else "")
return parts.hostname, (parts.port or default_port(parts.scheme)), host_header, path, f"{parts.scheme}://{parts.netloc}"With IMPL=wrong, 7 of 15 tests fail (5 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: each payload names an internal destination a different way. The secure
# outcome is the same every time: 400 and no connection to the internal service.
def test_exploit_loopback_ipv4_literal(env):
assert_blocked(env, "http://127.0.0.1/latest/meta-data/")
def test_exploit_loopback_decimal_integer(env):
assert_blocked(env, "http://2130706433/")
def test_exploit_loopback_octal(env):
assert_blocked(env, "http://0177.0.0.1/")
def test_exploit_loopback_hex(env):
assert_blocked(env, "http://0x7f.0.0.1/")
def test_exploit_ipv6_loopback_literal(env):
assert_blocked(env, "http://[::1]/")
def test_exploit_ipv4_mapped_ipv6_loopback(env):
assert_blocked(env, "http://[::ffff:127.0.0.1]/")
def test_exploit_cloud_metadata_address_by_hostname(env):
assert_blocked(env, "http://metadata.internal.example/latest/meta-data/")
def test_exploit_private_address_by_hostname(env):
assert_blocked(env, "http://db.internal.example/")
def test_exploit_name_resolving_to_loopback(env):
assert_blocked(env, "http://rebind.example/")
def test_exploit_redirect_from_allowed_host_to_internal(env):
assert_blocked(env, "http://files.partner.example.test/redirect-internal")- requests follows redirects by default (allow_redirects=True for GET), so a check on the first URL is undone by a 302 into the internal network; each hop is also resolved by name.
- Django's URLValidator and forms.URLField accept http://127.0.0.1/ and http://169.254.169.254/; they check that a string is a URL, not where it points.
- urllib.parse does not normalise IPv4 shorthand, so 2130706433, 0177.0.0.1 and 0x7f.0.0.1 reach a string blocklist unchanged and bypass it; only resolving or parsing the address catches them.
- ipaddress.ip_address('::ffff:127.0.0.1').is_loopback is True, but NAT64 64:ff9b::7f00:1 is reported is_global True; unwrap IPv4-mapped and NAT64 before trusting is_global.
- ALLOWED_HOSTS and SecurityMiddleware protect incoming requests; they do nothing for requests your server makes.
- socket.getaddrinfo can return several addresses; vet all of them, not just the first.
- A permitted host can still stream forever; cap bytes and set a timeout, and do slow fetches in a task queue rather than a request worker.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs ssrf/python-django does this for you).
- Each exploit asserts the request is refused with 400 and the internal test server records zero connection attempts, for 127.0.0.1, decimal/octal/hex, [::1], [::ffff:127.0.0.1], a name resolving internal, and a redirect to an internal host. impl/ and lib/ are byte-identical to the Flask and FastAPI cards; only the view and the test wiring differ.
- Against your own endpoint, request http://169.254.169.254/latest/meta-data/ and expect a refusal, and confirm a 302 to an internal host is not followed.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.django.security.injection.ssrf.ssrf-injection-requests.ssrf-injection-requests | No findings. The rule is file-local taint from a Django request into the requests library; this card reads the URL in fetcher/views.py and fetches with http.client in lib/ through an injected fetcher. The packs reported nothing about SSRF.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/django --config r/python.django.security.injection.ssrf.ssrf-injection-requests.ssrf-injection-requests --exclude tests . |
| CodeQL | py/full-ssrf | Django.qll makes request.GET a remote source and the query's sinks include http.client requests. Here the value reaches http.client through an injected resolver and a pinned IP, with the implementation loaded by importlib, so a tracked path is unlikely; the regression tests are the control.Checked: github/codeql main at b5b8165; not executed (the query's @id is py/full-ssrf) |
| Bandit | B104hardcoded_bind_all_interfaces (no SSRF plugin exists) | One finding, a false positive: the string "0.0.0.0" in the wrong implementation's blocklist (impl/wrong.py), as on the Flask and FastAPI cards. Bandit has no SSRF plugin.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 |
| django | 6.1.2 |
| http.client, socket, ipaddress (standard library) | Python 3.14.8 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/python-djangofolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs ssrf/python-django runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 12 of 15 tests fail (10 exploit tests); wrong: 7 of 15 tests fail (5 exploit tests)
- Test results
- IMPL=fixed 15/15 pass; IMPL=vulnerable 12/15 fail (all 10 exploit tests, plus the scheme and size-limit checks); IMPL=wrong 7/15 fail, the same tests the Flask and FastAPI cards fail. Offline: the tests use only loopback servers and an injected resolver.
- Dependency freshness
- django 6.1.2 (2026-10-06, a security release), pytest-django 4.14.0 (2026-08-10) and pytest 9.1.1 (2026-06-19) match the other Django cards. Python 3.14.8 is the 30 September 2026 security release.
- Scanners
- Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); CodeQL read from github/codeql main at b5b8165, not executed.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- CWE-918: Server-Side Request Forgery (SSRF)
- Python ipaddress: is_global, is_private, ipv4_mapped
- Python http.client: HTTPConnection
- Python socket.getaddrinfo
- Django 6.1: Validators (URLValidator)
- Requests: Redirection and History (redirects followed by default)
Attack patterns: