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

Fix SSRF in Python and Flask

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 hostname string. Allow only http/https and ports 80/443, reject credentials; resolve the host (or read an IP literal in any notation) and use the ipaddress module to reject loopback, private, link-local and IPv4-mapped addresses; connect to the vetted IP; re-validate every redirect; cap time and bytes.

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

Steps

  1. Find every server-side fetch built from request data (request.args, request.form, request.json): requests, urllib.request, httpx 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 recognise an IP literal, and classify each address with the ipaddress module; reject anything that is not is_global (loopback, private, link-local, carrier-grade NAT) 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). http.client gives you that control; requests needs a custom adapter or a connect-by-IP step.
  5. Handle redirects yourself: http.client does not follow them; with requests set allow_redirects=False and re-run the policy on each hop.
  6. Set a timeout and read the body in bounded chunks with a byte cap; log the parsed destination and reason server-side, never to the caller.

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

  • 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/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.
  • socket.getaddrinfo can return several addresses; vet all of them, not just the first that a connect would try.
  • requests follows redirects and re-resolves by name; validating then calling requests.get(url) reopens the rebinding window and follows a 302 into the internal network.
  • http.client.HTTPConnection(host).request() resolves host itself; set the connection's socket to the vetted IP (or subclass connect) so the check and the connection agree.
  • A permitted host can still stream forever; cap bytes and set a timeout.
  • Flask debug mode returns the exception and the failing URL to the caller; keep it off and return a generic error.

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 python-flask 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 Flask
ToolRuleWhat it found here
Semgreppython.django.security.injection.ssrf.ssrf-injection-requestsNo findings. This is the nearest SSRF rule in the CE registry; it is file-local taint from a Django request into requests.get/post, and this card uses the stdlib http.client with the URL read in app.py, so it does not match. Semgrep CE has no Flask/http.client SSRF rule.Checked: Semgrep 1.180.0 (CE); semgrep --config r/python.django.security.injection.ssrf.ssrf-injection-requests impl (Ran 1 rule)
CodeQLpy/full-ssrfThe Python SSRF query tracks remote input into HTTP client sinks (requests, urllib, http.client). Here the request value reaches http.client through an injected resolver and a pinned IP, so a tracked path is unlikely; the regression tests are the control.Checked: github/codeql main; not executed
Banditn/aNo SSRF pluginBandit has no SSRF/CWE-918 plugin, so it does not evaluate this card. The three-mode regression suite is the enforcement evidence.Checked: Bandit 1.9.4

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
werkzeug3.1.9
http.client, socket, ipaddress (standard library)Python 3.14.8
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-flaskfolder.

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

Download the project ZIP, 22 KB

Verified

Machine
macOS 27.0, Apple M5 Max
Toolchain
  • python 3.14.8
  • flask 3.1.3
  • pytest 9.1.1
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 fail (10 exploit); wrong: 7 fail (5 exploit)
Notes
urllib.parse keeps IPv4 shorthand verbatim, so the wrong implementation's string blocklist is bypassed by the decimal, octal and hex payloads here (unlike Node and .NET, whose URL parsers normalise them). Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

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. Requests: Redirection and History (allow_redirects)
  6. CWE-918: Server-Side Request Forgery (SSRF)
  7. 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.