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

Fix SSRF in Python and FastAPI

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 or its Pydantic type. Allow only http/https and ports 80/443, 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 FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 23 KB
On this page

Steps

  1. Find every server-side fetch built from request data in your path operations and background tasks: httpx or httpx2 (sync and async clients), requests, aiohttp, 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.
  3. Resolve the host with socket.getaddrinfo (or loop.getaddrinfo in async code), or recognise an IP literal, and classify each address with the ipaddress module; reject anything that is not is_global, plus IPv4-mapped and NAT64. 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 httpx that means a custom transport, not a check before client.get(url).
  5. Handle redirects yourself: keep follow_redirects off 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

  • Typing the parameter as HttpUrl is not a defence. Pydantic's URL type rejects ftp:// and parses like a browser, so 0177.0.0.1, 2130706433 and 0x7f.0.0.1 all come out as 127.0.0.1, but it accepts that, 169.254.169.254 and every other internal address.
  • 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.
  • httpx and httpx2 do not follow redirects unless follow_redirects=True, but a client created with it follows a 302 into the internal network and resolves each hop by name; validating first and then calling client.get(url) also reopens the rebinding window.
  • A blocking fetch inside async def stalls the event loop for every request. Use a plain def path operation, as this card does (FastAPI runs it in a thread pool), or an async client.
  • socket.getaddrinfo can return several addresses; vet all of them, not just the first that a connect would try.
  • A permitted host can still stream forever; cap bytes and set a timeout.

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-fastapi 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.
  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 FastAPI
ToolRuleWhat it found here
Semgreppython.django.security.injection.ssrf.ssrf-injection-requestsNo findings. It is the nearest SSRF rule in the CE registry and is file-local taint from a Django request into requests; this card reads the URL in a FastAPI path operation and fetches with http.client in lib/. The packs reported nothing about SSRF (p/default's one finding is the deliberate importlib.import_module in app.py).Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app.py impl lib
CodeQLpy/full-ssrfFastApi.qll makes the url query parameter 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, and the implementation is loaded with importlib.import_module, so a tracked path is unlikely; the regression tests are the control.Checked: github/codeql main at b5b8165; not executed
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. Bandit has no SSRF/CWE-918 plugin, so it does not evaluate this card.Checked: Bandit 1.9.4; bandit -r app.py impl lib

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
fastapi0.142.2
starlette1.7.0
http.client, socket, ipaddress (standard library)Python 3.14.8
httpx2 (TestClient transport, test only)2.13.1
pytest9.1.1

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

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-fastapi runs all three modes and checks the expected results.

Download the project ZIP, 23 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python CPython 3.14.8
  • fastapi 0.142.2
  • starlette 1.7.0
  • pytest 9.1.1
  • semgrep 1.180.0
  • bandit 1.9.4
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
fixed 15/15 pass; vulnerable 12/15 fail (10 exploit tests); wrong 7/15 fail (5 exploit tests), offline after install
Dependency freshness
FastAPI 0.142.2 (2026-09-30) and Pydantic 2.13.5 (2026-08-28) rather than 0.143.0 and 2.14.0 (2026-10-08), which fix no security issue; Starlette 1.7.0 (2026-09-23); httpx2 2.13.1 (2026-09-23); iniconfig 2.3.0 because 2.3.1 is from 2026-10-06. OSV reported no known vulnerabilities for any pinned package.
Notes
The fetcher (impl/ and lib/transport.py) is the Flask card's code unchanged: the policy lives in the connection, not in the framework. What is FastAPI-specific is app.py (a plain def path operation, generic 400 and 502 responses) and the HttpUrl pitfall, checked with Pydantic 2.13.5.

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. Python ipaddress: is_global, is_private, ipv4_mapped
  3. Python http.client: HTTPConnection
  4. Python socket.getaddrinfo
  5. Pydantic: network types (HttpUrl)
  6. FastAPI: concurrency and async / await (def path operations run in a thread pool)
  7. CWE-918: Server-Side Request Forgery (SSRF)
  8. RFC 4193: Unique Local IPv6 Unicast Addresses (fc00::/7)

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.