Flask · Server-side request forgery (SSRF)

Server-side request forgery (SSRF) in Flask

Flask is where small proxy endpoints get written (an image fetcher, a link preview, a CORS workaround), and each of them is an HTTP client that a visitor aims by filling in a url parameter.

Class
Server-side request forgery (SSRF) in Python
CWE
CWE-918
web framework
Flask documentation
On this page

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

  1. Search for requests., urllib.request.urlopen( and httpx. calls whose URL derives from request.
  2. Replace URL parameters with a service key wherever the upstreams are known.
  3. Remove substring and prefix checks on URLs.
  4. Set allow_redirects=False on user-directed fetches.
  5. Require IMDSv2 or the equivalent on cloud hosts, and restrict egress.

References

See it on your own Flask code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

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