How it arises in Flask
The pass-through proxy. A front-end cannot call a third-party API because of CORS, so someone adds /proxy?url=... to the Flask backend and returns whatever comes back. It reflects the response body, which makes it the most useful kind of SSRF to an attacker: internal pages and cloud metadata are returned verbatim.
Link previews and unfurlers. A chat or CMS feature fetches a pasted URL to extract a title and an image. The response is not reflected, but internal services that act on a GET are still reachable, and timing or error text often leaks whether a port is open.
Image and PDF pipelines. ?src= for a thumbnailer; an HTML-to-PDF endpoint that loads remote stylesheets and images named in the submitted HTML.
Substring allowlists. The first defence added is usually if "example.com" in url or url.startswith("https://api.example.com"). Both pass https://api.example.com.attacker.net/ and https://api.example.com@10.0.0.5/.
Redirects. Requests performs location redirection by default for all verbs except HEAD. A URL that passes any check on its text can respond with a redirect to http://localhost:5000/admin and the library will follow it.
Flask has no built-in HTTP client and therefore no setting that governs these calls. The route is a few lines, there is no form class to hang a validator on, and the check has to be written by hand.
Vulnerable and fixed
import requests
from flask import Flask, Response, request
app = Flask(__name__)
@app.get("/proxy")
def proxy():
upstream = requests.get(request.args["url"], timeout=5)
return Response(upstream.content, content_type=upstream.headers.get("Content-Type"))
If the set of upstreams is known, do not accept a URL at all: accept a key and a path. This removes the destination from the request:
import requests
from flask import Flask, Response, abort, request
app = Flask(__name__)
UPSTREAMS = {"weather": "https://api.weather.example", "rates": "https://rates.example"}
@app.get("/proxy/<service>/<path:resource>")
def proxy(service, resource):
base = UPSTREAMS.get(service)
if base is None or ".." in resource.split("/"):
abort(404)
upstream = requests.get(
f"{base}/{resource}", params=request.args, timeout=5, allow_redirects=False
)
return Response(upstream.content, content_type=upstream.headers.get("Content-Type"))
When arbitrary URLs are the feature, as in a link preview, parse with urllib.parse.urlsplit, resolve the hostname, reject any address for which ipaddress.ip_address(...).is_global is false, disable redirects, and place the fetcher on a network segment that cannot reach internal services.
What Flask and the libraries already do for you
Flask's routing lets you express the safe design directly: a string converter for the service key and a dictionary lookup. Werkzeug parses query strings so you never split URLs by hand. Requests exposes allow_redirects per call and records every hop in Response.history, which makes redirect handling auditable. Python's ipaddress module classifies loopback, link-local and private ranges for you. The piece that is yours is deciding which destinations are acceptable.
What es-python adds
Text checks on a URL are fragile because the URL is not what the network sees. es-python makes its decision later, on the connection itself. Every outbound call made with request-controlled data records the base events socket getaddrinfo and socket connect, so the events list shows each host your proxy was asked to reach. When the address is loopback, link-local or in a private range, an ssrf event is added, tagged ssrf in the events list.
This is what covers the two bypasses above. https://api.example.com@10.0.0.5/ passes a startswith test and still dials 10.0.0.5, and the event reports that address. A redirect followed inside requests leads to a second connect, to the internal target, which is reported like the first.
Read the absence of an event carefully. The address test recognises the standard dotted-quad and canonical IPv6 spellings, not every alternate encoding. A name is caught as internal when the program resolves it through Python. DNS rebinding is not covered. For those cases the base socket connect event on untrusted input is the backstop: it shows every destination a request steered, internal or not.
With the fixed proxy, the host comes from a dictionary in source, so the request no longer steers the destination. The Block backend network attacks template, or any Raise rule on ssrf for the socket source, raises a RuntimeError before the connection is made.
Review checklist
- Search for
requests.,urllib.request.urlopen(andhttpx.calls whose URL derives fromrequest. - Replace URL parameters with a service key wherever the upstreams are known.
- Remove substring and prefix checks on URLs.
- Set
allow_redirects=Falseon user-directed fetches. - Require IMDSv2 or the equivalent on cloud hosts, and restrict egress.
References
- Reference: Requests documentation: Quickstart, Redirection and History (checked 2026-10-12)
- Reference: Flask documentation: API, URL route registrations (checked 2026-10-12)
- Reference: Flask documentation: Security Considerations (checked 2026-10-12)