SSRF in Python services: requests, httpx, urllib and the cloud metadata endpoint

Server-side request forgery (SSRF) happens when a Python service fetches a URL that a client chose, so the client borrows the server's network position: it can reach localhost, private addresses and the cloud metadata endpoint at 169.254.169.254, which hands out credentials. The defence that holds is to decide on the address the connection will actually dial, on every hop, and to back that with network controls, because checks on the URL string are easy to get around. This article shows how the three common HTTP clients differ, what the metadata endpoint changes, the bypasses that matter, and code you can adapt.

SSRF is CWE-918. The 2025 OWASP Top 10 folds it into A01 Broken Access Control, which fits: the attacker gains access they were never granted, through a request your server makes for them.

Where it starts

Any feature that fetches a client-supplied address is a candidate: a webhook target, "import from URL", a link preview, an avatar URL, a PDF renderer that loads remote images, an OpenID or SAML metadata fetch. The handler is usually three lines:

import requests
from flask import Flask, request

app = Flask(__name__)


@app.post("/preview")
def preview():
    url = request.form["url"]
    response = requests.get(url, timeout=5)
    return {"length": len(response.text)}

Nothing here looks wrong, and against public addresses nothing is. The flaw is that the server will just as readily fetch http://127.0.0.1:6379/, http://10.0.0.12/admin or the metadata endpoint, none of which the client could reach directly.

How the three clients differ

The clients behave differently in ways that matter for SSRF, and each project documents its behaviour clearly.

Client Redirects by default Schemes Note
requests followed http, https "By default Requests will perform location redirection for all verbs except HEAD" (quickstart)
httpx not followed http, https "Unlike requests, HTTPX does not follow redirects by default" (compatibility guide)
urllib.request.urlopen followed http, https, ftp, file, data the default opener includes handlers for local files (urllib.request)

Two consequences follow. First, with requests and urllib, validating the URL you were given is not validating the URL that gets fetched: a public host can answer with a 302 to an internal one, and the client follows it. Second, urlopen accepts file: URLs, so a client-supplied URL passed to it is a local file read as well as a network request. Bandit reports this as B310. Restrict the scheme before anything else.

httpx's default is the safer one, and it is easy to undo: follow_redirects=True on the client restores the requests behaviour for every call made with it.

Why the metadata endpoint raises the stakes

On the major clouds a virtual machine can ask a link-local HTTP service about itself, and that service returns, among other things, temporary credentials for the role attached to the machine. All three use the same address:

  • AWS: http://169.254.169.254/latest/meta-data/. IMDSv2 requires a session token obtained with a PUT request and sent back in a header, and AWS lets you require it (instance metadata options).
  • Google Cloud: http://metadata.google.internal/computeMetadata/v1/, which requires the header Metadata-Flavor: Google (querying metadata).
  • Azure: http://169.254.169.254/metadata/instance, which requires the header Metadata: true (Instance Metadata Service).

Those header and method requirements exist because of SSRF: a plain GET to an attacker-chosen URL carries no custom header and cannot send a PUT, so the simplest form of the attack gets nothing back. They are a real improvement and worth enforcing. They do not cover a handler that lets the client set headers or the method (a "test this webhook" form often does both), and they do nothing for the other internal targets: an unauthenticated Redis, an admin port bound to localhost, a neighbouring service that trusts the private network.

The bypasses that matter

Filters on the URL string are attractive because they are short. These are the reasons they are not enough:

  • Redirects. Covered above: the first URL passes, the second is internal.
  • DNS. internal.example.com is a perfectly public-looking name that can resolve to 10.0.0.5. Any name the attacker controls can resolve to any address.
  • Alternate spellings. The system resolver accepts more than dotted quads. On Linux, socket.getaddrinfo("2852039166", 80) returns 169.254.169.254 and 0x7f.1 resolves to 127.0.0.1, so a deny-list of strings does not hold.
  • IPv6 forms. ::ffff:169.254.169.254 is the same destination written as an IPv4-mapped IPv6 address. Unwrap ipv4_mapped before testing.
  • Resolve twice. If your check resolves the name and the HTTP client resolves it again, an attacker's DNS server can answer the first lookup with a public address and the second with an internal one (DNS rebinding).
  • URL parser differences. Two parsers can disagree on which part of http://a@b or a URL with unusual characters is the host. Take the host from the same parser the client uses, or from the connection itself.

A check that holds up

Decide on the resolved address, not the string, and do it for every hop. The standard library's ipaddress module carries the classification, so there is no need to maintain your own list of ranges:

import ipaddress
import socket
from urllib.parse import urljoin, urlparse

import requests

ALLOWED_SCHEMES = {"http", "https"}
MAX_HOPS = 3


class ForbiddenDestination(Exception):
    pass


def _check_address(text: str) -> None:
    address = ipaddress.ip_address(text)
    mapped = getattr(address, "ipv4_mapped", None)
    if mapped is not None:
        address = mapped
    if not address.is_global or address.is_multicast:
        raise ForbiddenDestination(text)


def check_url(url: str) -> None:
    parts = urlparse(url)
    if parts.scheme not in ALLOWED_SCHEMES or not parts.hostname:
        raise ForbiddenDestination(url)
    port = parts.port or (443 if parts.scheme == "https" else 80)
    for info in socket.getaddrinfo(parts.hostname, port, proto=socket.IPPROTO_TCP):
        _check_address(info[4][0])


def fetch(url: str) -> requests.Response:
    for _ in range(MAX_HOPS):
        check_url(url)
        response = requests.get(url, timeout=5, allow_redirects=False)
        if not response.is_redirect:
            return response
        url = urljoin(url, response.headers["Location"])
    raise ForbiddenDestination("too many redirects")

is_global is the allow-shaped test: it is false for private, loopback, link-local, reserved and shared address space, so new special ranges are excluded by default. Redirects are disabled on the client and followed by hand, with the check repeated on each hop.

This code still resolves the name twice, once in check_url and once inside requests, so it narrows the rebinding window without closing it. Closing it means dialling the address you checked. In requests that is a custom transport adapter; in httpx it is a custom transport. Both are more code than most teams want to own, which leads to the layer that does not depend on application code at all.

Controls outside the code

  1. Send outbound fetches through an egress proxy with an allowlist. One policy, enforced in one place, for every call site and every library, including the ones you have not found yet.
  2. Require the token-based metadata service. On AWS, require IMDSv2 and keep the response hop limit at its minimum so a container behind an extra network hop does not receive the token. Apply the equivalent settings on the other clouds.
  3. Give the machine's role only what the service needs. If a credential does leak, its permissions are the blast radius.
  4. Put the fetcher in its own network segment when fetching arbitrary URLs is the product (crawlers, preview services), so "internal" contains nothing of value.

Finding SSRF in an existing service

Search for the call sites: requests., httpx., urlopen(, aiohttp, urllib3, pycurl, and libraries that fetch on your behalf (PDF and image renderers, XML parsers with external entities, feed readers). For each, trace the URL back. Static search does this part well when the request value reaches the client in the same function. It has a harder time when the URL is stored first (a webhook saved today and called by a worker tonight), built from pieces, or passed into a third-party library that makes the request several calls down.

That is the part a runtime view adds. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself: the point where the connection is made. Whichever client library made the call, an untrusted destination produces a socket connect, socket getaddrinfo or socket gethostbyname event, and when the address being dialled is loopback, link-local or in a private range, an ssrf event fires alongside it. Because the decision is made on the address the program is dialling, an in-application redirect to an internal address is reported too (what the event looks like).

The limits are worth knowing. The address test recognises the standard dotted-quad and canonical IPv6 spellings, not alternate encodings of the same address; a name that resolves internally is caught only when the program resolves it explicitly through Python; and there is no DNS-rebinding coverage. So treat the ssrf label as a strong signal when it fires, and the base socket connect event on untrusted input as the list of every outbound destination a request steered. An event shows that the flow happened, with the value, its source and the call path; whether the destination was reachable and useful to an attacker is your judgement. Under a Raise rule the connection is stopped before it is made. The model behind this is on How EyalSec works, and costs are on pricing.

Summary

Layer Do this
Scheme allow http and https only; never pass a client URL to urlopen unchecked
Destination resolve, unwrap IPv4-mapped addresses, require is_global
Redirects disable automatic following; re-check every hop
Metadata require the token or header-based service; minimal role permissions
Network egress proxy with an allowlist; isolate fetchers
Evidence watch which outbound connections untrusted input actually steers

If you want to see which outbound connections on your own services are steered by untrusted input, you can book a live demo.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo