Server Side Request Forgery (SSRF) in Flask Applications
Server-Side Request Forgery (SSRF) is a vulnerability that occurs when an application can be tricked into making unintended HTTP requests to internal or external resources, on behalf of the attacker. The attacker abuses server-side functionality that performs HTTP requests, often by supplying crafted URLs as input.
The danger with SSRF lies in the fact that the server usually has access to internal services, metadata endpoints, or private IP ranges that are not exposed to the internet.
If the server does not properly validate or sanitize the user-supplied URL, the attacker may exploit this to access sensitive resources, escalate privileges, or pivot further into the internal network.
In many cases, SSRF vulnerabilities occur in seemingly harmless features such as image fetching, URL previews, or data import mechanisms.
To explore SSRF more, we have the following Flask application provides a simple weather lookup interface.
Users can select a city to query weather data for, and the server fetches the corresponding data from a backend weather API.
from flask import Flask, request, jsonify, render_template_string
import requests
app = Flask(__name__)
CITIES = ["Amman", "Gaza", "Cairo", "Jerusalem"]
HTML_TEMPLATE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Weather Query</title>
</head>
<body>
<h1>Weather Lookup</h1>
<ul>
{% for city in cities %}
<li><a href="/fetch_weather?weather_request=http://api.weather.local/data?city={{ city }}">{{ city }}</a></li>
{% endfor %}
</ul>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE, cities=CITIES)
@app.route('/fetch_weather', methods=['GET'])
def fetch_weather():
weather_request = request.args.get('weather_request')
try:
response = requests.get(weather_request, timeout=5)
data = response.text
return jsonify({"weather": data}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
The application renders an HTML page with a few predefined set of cities, Each city is linked to the /fetch_weather endpoint and passes the target API URL as a query parameter weather_request:
/fetch_weather?weather_request=http://api.weather.local/data?city=Gaza
When a request is made to /fetch_weather, the server extracts the weather_request parameter directly from the query string:
weather_request = request.args.get('weather_request')
Then, it uses Python’s requests.get function to send a GET request to the provided URL:
response = requests.get(weather_request, timeout=5)
Once the request is made, the response body is saved in the data variable and returned to the user.
response = requests.get(weather_request, timeout=5)
data = response.text
Since the application trusts user-supplied input for outbound requests, an attacker can fully control the destination URL that the server will access:
http://target-server/fetch_weather?weather_request=http://internal-service.local
For example, we can hit api.ipify.org to retrieve the external IP for the vulnerable instance:
hackpad :: PyFu/flask-fu/flask-ssrf » curl http://localhost:5000/fetch_weather\?weather_request\=https://api.ipify.org | jq
{
"weather": "89.XXX.XXX.XXX"
}
We can see that we got the response from api.ipify.org and saved as value of the weather key as expected.
This example presents a very simple Flask application to provide a basic understanding of how SSRF vulnerabilities can appear in Python code.
While the functionality looks harmless, the lack of input validation on user-supplied URLs creates a dangerous trust boundary violation that is often found in real-world web applications.
An attacker who successfully exploits this SSRF flaw can abuse the server to send requests to internal systems that are not directly accessible from the internet.
This can include internal administrative interfaces, cloud metadata services, or backend systems which potentially expose sensitive data.
Why SSRF in Flask matters from an offensive security perspective
I keep SSRF high on my priority list because the server’s network position is the real payload. A Flask app sitting inside a VPC or a container can reach the loopback interface, private subnets, sidecar admin ports, and the cloud metadata endpoint, none of which I can touch directly. The moment the app fetches a URL I control, that reach becomes mine. On most cloud deployments the first stop is 169.254.169.254 to pull instance credentials, which escalates a weather-lookup feature straight into account takeover.
In Flask specifically the bug hides inside features that are meant to be helpful, so I look for the request-fetches-a-URL shape rather than an obvious sink. The tells I audit for:
- A request value flowing into an outbound fetch.
request.args,request.form, or JSON fields reachingrequests.get,urllib.request.urlopen, orhttpxwith no allowlist in between. - URL-shaped parameters by name.
url,weather_request,callback,webhook,next,image,feed, andtargetare where developers expect a benign upstream and validate nothing. - Server-side rendering and integration glue. Link unfurlers, thumbnail and PDF generators, OAuth/webhook callbacks, and “import from URL” flows fetch attacker-named hosts as a core feature.
- No filtering on scheme, host, or redirect. If the code does not resolve and re-check the host,
file://,gopher://, decimal IPs, and a permitted host that redirects inward all get through.
The defender takeaway: never treat a user-supplied URL as an upstream you trust; validate after resolution and revalidate every redirect. The library-level mechanics of the same flaw are covered in Python Requests Library SSRF via URL Parsing.
Proof of exploitation
Run the lab app (PyFuLabs/flask-fu/flask-ssrf). The weather_request URL is fetched server-side with no validation, so pointing it at a loopback address the client cannot reach returns the internal service’s own response:
curl -sG "http://pyfu.local/flask-fu/flask-ssrf/fetch_weather" \
--data-urlencode "weather_request=http://127.0.0.1:5000/"
{
"weather": "<!doctype html>\n<html lang=\"en\">\n <head>\n <title>Weather Query</title>\n ..."
}
The server fetched 127.0.0.1 on the attacker’s behalf; the same primitive reaches cloud metadata endpoints and internal-only services.
Mitigation
The fix is to stop treating a user-supplied URL as trustworthy. Validate it against an allowlist of permitted schemes and hosts, resolve the hostname and reject any address that falls in private, loopback, link-local, or cloud-metadata ranges, and re-check after resolution to defeat DNS rebinding. Disable redirects or revalidate every hop so a permitted host cannot bounce the request inward, set a short timeout, and where the application only ever talks to a known upstream, route outbound fetches through an egress proxy that enforces the allowlist so the application server cannot reach internal services directly.