flawopen.com/Server-Side Request Forgery/Python
You send an assistant to buy a newspaper at a store. But the address you gave is 'the safe in the manager's office'. If the assistant doesn't verify that the address is a public shop, he walks into the office and hands you company secrets.
When a Python app executes requests.get(user_url), the HTTP client blindly establishes a TCP connection to whatever host the user specifies. Attackers provide loopback addresses (127.0.0.1), internal private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), or link-local cloud metadata addresses (169.254.169.254).
In 2019, the Capital One breach occurred when an attacker exploited an SSRF vulnerability in a WAF running on AWS EC2, querying the IMDS endpoint at 169.254.169.254 to steal IAM role credentials and exfiltrate 100M+ customer records.
CISA Cybersecurity Advisory & MITRE CVE repository.# VULNERABLE: Naive requests.get to user-provided URL
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/fetch-avatar')
def fetch_avatar():
url = request.args.get('url')
# Attacker passes: http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Server queries AWS metadata and returns cloud credentials!
resp = requests.get(url, timeout=5)
return resp.content, 200, {'Content-Type': resp.headers.get('Content-Type', 'image/png')}
# HARDENED: Resolve DNS, parse IP address, deny private/link-local CIDRs
import socket
import ipaddress
import urllib.parse
import requests
from flask import Flask, request, abort
app = Flask(__name__)
BLOCKED_NETWORKS = [
ipaddress.ip_network('127.0.0.0/8'), # Loopback
ipaddress.ip_network('10.0.0.0/8'), # RFC 1918 Private
ipaddress.ip_network('172.16.0.0/12'), # RFC 1918 Private
ipaddress.ip_network('192.168.0.0/16'), # RFC 1918 Private
ipaddress.ip_network('169.254.0.0/16'), # Link-local / Cloud IMDS
ipaddress.ip_network('::1/128'), # IPv6 Loopback
ipaddress.ip_network('fc00::/7'), # IPv6 Private ULA
]
def is_safe_ip(ip_str):
ip = ipaddress.ip_address(ip_str)
return not any(ip in net for net in BLOCKED_NETWORKS)
@app.route('/fetch-avatar')
def fetch_avatar():
url = request.args.get('url', '')
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
abort(400, "Invalid URL scheme")
# 1. Resolve all DNS IPs for hostname
try:
addr_info = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == 'https' else 80))
resolved_ips = [ai[4][0] for ai in addr_info]
except socket.gaierror:
abort(400, "Unable to resolve host")
# 2. Verify all resolved IPs are public
for ip in resolved_ips:
if not is_safe_ip(ip):
abort(403, "Access to internal IP address is prohibited")
# 3. Fetch with redirects disabled to prevent DNS rebinding or redirect bypass
resp = requests.get(url, timeout=3, allow_redirects=False)
return resp.content, 200, {'Content-Type': resp.headers.get('Content-Type', 'image/png')}
The hardened code resolves the hostname to its underlying IP addresses and tests each IP against private, loopback, and link-local CIDR subnets using ipaddress.ip_address(). Setting allow_redirects=False prevents an attacker from supplying an allowed external URL that 302-redirects to an internal address.
Resolving the IP before calling requests.get leaves a microsecond window where DNS could change. For total protection in zero-trust environments, connect directly to the resolved IP using a custom HTTP adapter or proxy.
Attackers use http://0177.0.0.1 or http://2130706433 to evade string filters. ipaddress.ip_address correctly parses all standard integer and octal representations.
Regex domain blacklists fail against DNS rebinding, alternative IP notations, and IPv6 equivalents like [::1] or [0:0:0:0:0:ffff:7f00:1].
grep -rn "requests\.get(" --include="*.py" .
# Bandit rule B113: bandit -r . -t B113
http and httpsallow_redirects=False)169.254.169.254 is the standard link-local IP used by AWS, Google Cloud, Azure, and OpenStack to serve instance identity, cloud config, and temporary IAM authentication tokens.