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.
- Find every server-side fetch built from request data (request.args, request.form, request.json): requests, urllib.request, httpx 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.
- 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.
- 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.
- Handle redirects yourself: http.client does not follow them; with requests set allow_redirects=False and re-run the policy on each hop.
- 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.
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")- 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.
- 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).
- 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.
- 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 | No 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) |
| CodeQL | py/full-ssrf | The 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 |
| Bandit | n/aNo SSRF plugin | Bandit 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 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| http.client, socket, ipaddress (standard library) | Python 3.14.8 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/python-flaskfolder.
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-flask runs all three modes and checks the expected results.
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
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- Python ipaddress: is_global, is_private, ipv4_mapped
- Python http.client: HTTPConnection
- Python socket.getaddrinfo
- Requests: Redirection and History (allow_redirects)
- CWE-918: Server-Side Request Forgery (SSRF)
- RFC 4193: Unique Local IPv6 Unicast Addresses (fc00::/7)
Attack patterns: