Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix SSRF in Python and Django

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Django 6.1.2
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 25 KB
On this page

Steps

  1. Find every server-side fetch built from request data in your views, management commands and Celery tasks: requests, httpx, urllib.request and http.client.
  2. 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.
  3. 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.
  4. 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).
  5. Handle redirects yourself: turn automatic redirects off (requests follows them by default) and run the full policy on each hop.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
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

Fixedimpl/fixed.pyPython
# 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

Fixedimpl/fixed.pyPython
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

Wrong fiximpl/wrong.pyPython
# 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 regression test

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.

Testtests/test_fetch.pyPython
# 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")

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix SSRF in Python and Django
ToolRuleWhat it found here
Semgreppython.django.security.injection.ssrf.ssrf-injection-requests.ssrf-injection-requestsNo 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 .
CodeQLpy/full-ssrfDjango.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)
BanditB104hardcoded_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

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkDjango 6.1.2
django6.1.2
http.client, socket, ipaddress (standard library)Python 3.14.8
pytest9.1.1
pytest-django4.14.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/python-djangofolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 25 KB

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

  1. OWASP Server-Side Request Forgery Prevention Cheat Sheet
  2. CWE-918: Server-Side Request Forgery (SSRF)
  3. Python ipaddress: is_global, is_private, ipv4_mapped
  4. Python http.client: HTTPConnection
  5. Python socket.getaddrinfo
  6. Django 6.1: Validators (URLValidator)
  7. Requests: Redirection and History (redirects followed by default)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.