Web Security (OWASP Top 10, fix-side) · lesson 3 of 6
Beginner
3Intermediate
3Also part of Web Security (OWASP Top 10, fix-side)
12- + Python Security from Python
- + Database Security from SQL
- + JavaScript Security Best Practices from JavaScript
- + Java Security from Java
- + Bash Security Best Practices from Bash
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + FastAPI Roles, CORS and Settings from Environment Variables from FastAPI
- + Spring Security — Authentication and Authorization from Java
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + C Secure Coding: Buffer Overflows, Sanitizers, and Safe Functions from C
- + LLM Security Beyond Guardrails from LLM Engineering
- + Dependency Scanning (SCA) and SBOMs with pip-audit, Syft and Grype from DevSecOps (AppSec in the pipeline)
Also part of: Ethical Hacking (methodology, lab-only)
SSRF: Allowlists, Resolved-IP Checks and No Redirects
A link-preview endpoint fetches an internal admin page for anyone. Fix it with a host allowlist, resolved-IP checks and no redirects, under pytest.
By SkillByExample · Published
Server-side request forgery (SSRF) turns your server into the attacker’s HTTP client. Any feature that fetches a URL the user supplies, such as a link preview, a webhook or an “import from URL” button, can be pointed at addresses only the server can reach. This lesson builds a link-preview endpoint in Flask that reads an internal admin page, proves the bug with a failing test, then fixes it in three layers. To learn the attack side, use PortSwigger’s SSRF labs.
The environment is the one from the first lesson:
Python 3.12.10, Flask 3.1.3 (Werkzeug 3.1.9) and pytest 9.1.1 in a virtual environment.
Everything runs on 127.0.0.1, and the tests make no network requests at all.
A preview endpoint that fetches anything
Two apps. The first is an “internal” admin service that listens only on localhost, the way a metrics page or a database console often does:
# internal.py: an admin service that only listens on localhost
from flask import Flask
app = Flask(__name__)
@app.get("/admin/config")
def config():
return "db_password=not-a-real-secret\n"
The second is the public app, with a preview endpoint that fetches whatever URL it’s given:
# app.py
from urllib.request import urlopen
from flask import Flask, abort, request
app = Flask(__name__)
def fetch(url):
with urlopen(url, timeout=3) as resp:
return resp.read(200)
@app.get("/preview")
def preview():
url = request.args.get("url") or abort(400)
return fetch(url), 200, {"Content-Type": "text/plain"}
Start them in two terminals with flask --app internal run --port 5002 and
flask --app app run --port 5001. Then send one request to the public app, asking it to preview
the internal page:
$ curl -s "http://127.0.0.1:5001/preview?url=http://127.0.0.1:5002/admin/config"
db_password=not-a-real-secret
The public app read a page that nobody outside the machine can reach, and handed it back. Port 5002 isn’t the issue: the request to it came from the server itself, so “only listens on localhost” protected nothing. In the cloud the same request aimed at the instance metadata service can return credentials, which is why OWASP ranks SSRF among the top web risks (its own category in 2021, part of Broken Access Control in 2025).
Tests that prove it
Two fixtures keep the tests offline. dns replaces socket.getaddrinfo with a lookup table, and
fetched replaces fetch with a stub that records each URL instead of fetching it:
# conftest.py
import socket
import pytest
import app as app_module
@pytest.fixture
def dns(monkeypatch):
table = {"images.example.com": "93.184.215.14"} # fake DNS: no real lookups
monkeypatch.setattr(socket, "getaddrinfo", lambda host, *a, **k: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (table[host], 0))])
return table
@pytest.fixture
def fetched(monkeypatch, dns):
calls = []
monkeypatch.setattr(app_module, "fetch", lambda url: calls.append(url) or b"ok")
return calls
@pytest.fixture
def client(fetched):
return app_module.app.test_client()
The tests state the rule: the one image host the feature needs is fetched, and everything else is refused before any request goes out.
# test_ssrf.py
def test_allowed_host_is_fetched(client, fetched):
r = client.get("/preview?url=https://images.example.com/cat.png")
assert r.status_code == 200
assert fetched == ["https://images.example.com/cat.png"]
def test_internal_address_is_refused(client, fetched):
r = client.get("/preview?url=http://127.0.0.1:5002/admin/config")
assert r.status_code == 400
assert fetched == []
def test_allowed_name_resolving_inside_is_refused(client, dns):
dns["images.example.com"] = "127.0.0.1"
r = client.get("/preview?url=https://images.example.com/cat.png")
assert r.status_code == 400
def test_non_http_scheme_is_refused(client):
r = client.get("/preview?url=file:///etc/passwd")
assert r.status_code == 400
$ pytest -q --tb=short
.FFF [100%]
================================== FAILURES ===================================
______________________ test_internal_address_is_refused _______________________
test_ssrf.py:10: in test_internal_address_is_refused
assert r.status_code == 400
E assert 200 == 400
E + where 200 = <WrapperTestResponse streamed [200 OK]>.status_code
________________ test_allowed_name_resolving_inside_is_refused ________________
test_ssrf.py:17: in test_allowed_name_resolving_inside_is_refused
assert r.status_code == 400
E assert 200 == 400
E + where 200 = <WrapperTestResponse streamed [200 OK]>.status_code
_______________________ test_non_http_scheme_is_refused _______________________
test_ssrf.py:22: in test_non_http_scheme_is_refused
assert r.status_code == 400
E assert 200 == 400
E + where 200 = <WrapperTestResponse streamed [200 OK]>.status_code
=========================== short test summary info ===========================
FAILED test_ssrf.py::test_internal_address_is_refused - assert 200 == 400
FAILED test_ssrf.py::test_allowed_name_resolving_inside_is_refused - assert 2...
FAILED test_ssrf.py::test_non_http_scheme_is_refused - assert 200 == 400
3 failed, 1 passed in 0.08s
Only the happy path passes. The file: test matters as much as the loopback one: urlopen
also opens file: URLs, so without the stub that request would have read a file from the
server’s disk. The third test is the subtle one. It allowlists the right name, but the
name resolves to 127.0.0.1, and a fix that only compares hostnames would let it through.
The fix: allowlist the host, then check every address
Add three imports and an allowlist to the top of app.py, add is_safe_url, and make preview
call it before fetching:
import ipaddress
import socket
from urllib.parse import urlsplit
ALLOWED_HOSTS = {"images.example.com"}
def is_safe_url(url):
parts = urlsplit(url)
if parts.scheme not in {"http", "https"} or parts.hostname not in ALLOWED_HOSTS:
return False
addresses = {info[4][0] for info in socket.getaddrinfo(parts.hostname, None)}
return all(ipaddress.ip_address(a).is_global for a in addresses)
@app.get("/preview")
def preview():
url = request.args.get("url") or abort(400)
if not is_safe_url(url):
abort(400)
return fetch(url), 200, {"Content-Type": "text/plain"}
$ pytest -q --tb=short
.... [100%]
4 passed in 0.03s
The check runs in a fixed order. The scheme must be http or https, which shuts out file:.
The host must be one the feature actually needs. Then every address the name resolves to must be
globally routable: is_global is false for loopback, private ranges such as 10.0.0.0/8,
link-local addresses including 169.254.169.254, and reserved ranges. Checking the parsed
address also covers spellings that string comparisons miss, such as IPv6 ::1.
Restart the public app and send the original request again:
$ curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:5001/preview?url=http://127.0.0.1:5002/admin/config"
400
Redirects go around the check
is_safe_url approves the URL the user sent, but urlopen follows redirects, so an approved
server can answer 302 and send the fetch somewhere else. This stand-in plays an allowed host
that redirects to the admin page:
# redirector.py: stands in for an allowed host that answers with a redirect
from flask import Flask, redirect
app = Flask(__name__)
@app.get("/cat.png")
def cat():
return redirect("http://127.0.0.1:5002/admin/config")
Run it with flask --app redirector run --port 5003, keep internal running, and call fetch
directly. It runs on localhost, so is_safe_url would refuse it before fetch ever ran:
$ python -c "from app import fetch; print(fetch('http://127.0.0.1:5003/cat.png'))"
b'db_password=not-a-real-secret\n'
The first hop passed every rule and the second hop was never checked. Replace the urlopen
import with from urllib.request import HTTPRedirectHandler, build_opener, and replace fetch
with one that refuses redirects:
class NoRedirects(HTTPRedirectHandler):
def redirect_request(self, *args):
return None # urllib then raises HTTPError for the 3xx
def fetch(url):
with build_opener(NoRedirects).open(url, timeout=3) as resp:
return resp.read(200)
$ python -c "from app import fetch; print(fetch('http://127.0.0.1:5003/cat.png'))" 2>&1 | tail -1
urllib.error.HTTPError: HTTP Error 302: FOUND
The redirect is now an error instead of a second request. The four tests still pass, since they
stub fetch. One gap remains for high-risk apps: the check resolves the name and urlopen
resolves it again when it connects, so a DNS server that changes its answer between the two
(DNS rebinding) can still slip through. The full fix connects to the exact address that passed
the check, or sends all outbound requests through an egress proxy that enforces the same rules.
Practice
- The feature now needs a second image host,
cdn.example.com. Allow it and write a test that a URL on it is fetched when it resolves to a public address.
Solution
ALLOWED_HOSTS = {"images.example.com", "cdn.example.com"}
def test_second_host(client, dns, fetched):
dns["cdn.example.com"] = "93.184.215.15"
assert client.get("/preview?url=https://cdn.example.com/a.png").status_code == 200
The fake DNS table is a plain dict, so a test can add a name to it. Without the entry, the fake
getaddrinfo raises KeyError, which is a useful reminder that tests must never fall through to
a real lookup.
- Write a test where
images.example.comresolves to the IPv6 loopback address::1. Does it pass without changing the app?
Solution
def test_ipv6_loopback_refused(client, dns):
dns["images.example.com"] = "::1"
assert client.get("/preview?url=https://images.example.com/a.png").status_code == 400
It passes unchanged. ipaddress.ip_address("::1").is_global is False, so IPv6 needs no
special case. A check written as addr.startswith("127.") would have missed it.
- A user sends
http://images.example.com@127.0.0.1:5002/admin/config. Write a test that it’s refused, and explain which part ofis_safe_urlstops it.
Solution
def test_userinfo_trick_refused(client, fetched):
r = client.get("/preview?url=http://images.example.com@127.0.0.1:5002/admin/config")
assert r.status_code == 400 and fetched == []
Everything before @ is the user-info part of the URL, so urlsplit(...).hostname is
127.0.0.1, which isn’t in the allowlist. A check that searched the raw string for the allowed
name would have passed this URL.
- With redirects refused, a redirecting host now makes
previewcrash with a 500. Return a 502 instead, and test it with afetchstub that raisesHTTPError.
Solution
Add from urllib.error import URLError and catch it in preview (HTTPError is a subclass):
try:
body = fetch(url)
except URLError:
abort(502)
return body, 200, {"Content-Type": "text/plain"}
from urllib.error import HTTPError
def test_redirect_becomes_502(client, monkeypatch):
import app as app_module
def boom(url):
raise HTTPError(url, 302, "FOUND", {}, None)
monkeypatch.setattr(app_module, "fetch", boom)
assert client.get("/preview?url=https://images.example.com/a.png").status_code == 502
A 502 says “the upstream server misbehaved” without leaking the traceback, and the message never includes the internal URL the redirect pointed at.