Skip to main content
Web Security (OWASP Top 10, fix-side) · lesson 3 of 6
Web Security (OWASP Top 10, fix-side) intermediate Lesson 3 of 6

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

web-security owasp ssrf flask pytest

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

  1. 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.

  1. Write a test where images.example.com resolves 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.

  1. 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 of is_safe_url stops 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.

  1. With redirects refused, a redirecting host now makes preview crash with a 500. Return a 502 instead, and test it with a fetch stub that raises HTTPError.
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.

Frequently Asked Questions

What is server-side request forgery (SSRF)?
SSRF is when an app fetches a URL that the user supplies, and the user points it at something only the server can reach: an admin service on localhost, a database port, or a cloud metadata endpoint. The request comes from inside the network, so firewalls that block outsiders don't stop it. Link previews, webhooks, image importers and PDF renderers are the usual places it appears.
Is a host allowlist enough to stop SSRF?
No. An allowlisted name can resolve to a private address, either through misconfigured DNS or deliberately, and an allowlisted server can answer with a redirect to an internal URL. A sound fix allowlists the host, resolves it and rejects any address that isn't globally routable, and refuses to follow redirects.
Why check the IP address after DNS resolution instead of the hostname?
Because the hostname says nothing about where the connection goes. A name you allow can point at 127.0.0.1, and decimal, octal or IPv6 spellings of an address slip past string checks. Python's ipaddress module classifies the resolved address directly, and is_global is false for loopback, private, link-local and reserved ranges alike.
Do redirects matter for SSRF?
Yes. Python's urllib, requests and most HTTP clients follow redirects by default, so a URL that passes every check can answer 302 and send the client to an internal address. Turn redirects off for user-supplied URLs, or re-run the full URL check on every hop before following it.
What is DNS rebinding in SSRF?
DNS rebinding is when a name resolves to a public address while the app checks it, and to an internal one a moment later when the app connects. Checking and then fetching by name leaves that gap open. The full defence is to connect to the exact IP address that passed the check, or to send outbound requests through an egress proxy that enforces the same rules.