TRENDING
Subway turnstiles showing a green ENTER sign and a red DO NOT ENTER sign side by side
September 27, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
Macro photo of a brass keyhole with a key partially inserted in a wooden door
September 27, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel
Akamai's glass headquarters tower in Cambridge, Massachusetts, with the company's logo visible on the facade
September 27, 2026
Anthropic’s $11.6 Billion Akamai Deal Flips the Usual AI Financing Script
A staircase of sequential canal lock chambers at Bingley Five Rise Locks, each gate validating the water level before the next stage
September 27, 2026
How to Build a Multi-Stage AI Agent Pipeline in Python to Stop Errors From Compounding
The E. Barrett Prettyman United States Court House in Washington, D.C., home to the U.S. Court of Appeals for the D.C. Circuit
September 27, 2026
The D.C. Circuit’s 2-1 Ruling Turns Anthropic’s Own Guardrails Into a Supply-Chain Risk
27 Sep 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Five alphabetical thumb-index tabs cut into the edge of a dictionary, each labeled with a letter range
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
Five sample state-issued EBT benefit cards fanned out on a white background
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
A real wooden outdoor sandbox filled with sand and toys, empty of people
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
September 26, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 209 Posts
News 210 Posts
Learning Hub 180 Posts
Home/Learning Hub/How to Prevent Server-Side Request Forgery (SSRF) in a Python Web App
Learning Hub

How to Prevent Server-Side Request Forgery (SSRF) in a Python Web App

A URL-fetching feature can become a proxy into your own network: learn to reproduce the attack, defeat two real bypasses of a naive fix, and close it for good in Python.

September 19, 2026 16 Min Read
19

If your application ever fetches a URL that a user gives it (a webhook tester, a link preview, an avatar-from-URL uploader, a PDF renderer), it can be tricked into making requests you never intended. That class of bug is called Server-Side Request Forgery (SSRF): the server, not the browser, makes the request, so anything the server’s own network can reach becomes reachable through a public form field. In this tutorial you will build a small vulnerable feature, attack it for real, and then fix it properly, including the two ways a naive fix still gets bypassed.

Table Of Content

  • What Is SSRF, and Why Should You Care?
  • A Real-World Example: The 2019 Capital One Breach
  • What You’ll Build
  • Prerequisites
  • Step 1: Build the Feature That Will Become Vulnerable
  • Step 2: Set Up Two Things This Feature Should Never Be Able to Reach
  • An “Internal Only” Admin Service
  • A Stand-In for a Cloud Metadata Service
  • Step 3: Attack Your Own Feature
  • Step 4: Block Private, Loopback, and Link-Local Addresses
  • Why is_private Alone Almost Covers Everything
  • Step 5: The Gotcha That Breaks Naive Validation: DNS Rebinding
  • Reproducing It in a Lab, Safely
  • Step 6: Fix It by Resolving Once and Pinning the Connection
  • Step 7: Redirects Are a Second Way Around Validation
  • Step 8: Block Dangerous URL Schemes Too
  • Step 9: Wire It Into the Real Feature
  • Step 10: Automate Every Check With pytest
  • Common Mistakes and Gotchas
  • Blocklists Miss Things; Allowlists Don’t
  • Numeric IP Literals Are a Real, Platform-Dependent Bypass
  • Pinning an IP Breaks HTTPS Unless You Handle SNI
  • Application-Layer Checks Are Not Enough on Their Own
  • How to Verify It All Works End to End
  • Next Steps

What Is SSRF, and Why Should You Care?

A normal web request has the browser, sitting on the user’s own network, ask a server for something. SSRF flips that: the attacker gets the server to make the request instead, from the server’s own vantage point inside your infrastructure. If your server can reach an internal admin panel, a database’s management port, or a cloud provider’s instance metadata service, and your URL-fetching feature does not check where it is being told to go, an attacker can use that feature as a proxy into places they could never reach directly.

OWASP’s Server-Side Request Forgery Prevention Cheat Sheet defines the core problem plainly: SSRF is “an attack vector that abuses an application to interact with the internal/external network or the machine itself,” and it specifically calls out that “SSRF is not limited to the HTTP protocol,” since a vulnerable feature can sometimes be coaxed into speaking file://, gopher://, dict://, or other schemes entirely.

A Real-World Example: The 2019 Capital One Breach

The most consequential SSRF incident on record is the 2019 Capital One breach. According to Wiz’s Cloud Threat Landscape writeup of the incident, the root cause was “a Server Side Request Forgery (SSRF) vulnerability in a Web Application Firewall (WAF) named ‘ModSecurity’, which allowed the hacker to abuse it to relay requests to the instance metadata service (IMDS).” The same WAF also happened to have “excessive IAM permissions,” so once the attacker used the SSRF bug to pull temporary AWS credentials out of the metadata service, those credentials were valid for listing and reading private S3 buckets. Over 100 million credit applications, roughly 30GB of data, were exfiltrated as a result. The attacker, Paige Thompson, was arrested and later convicted; AWS’s own account of what changed afterward is direct about the connection: AWS’s documentation for its hardened metadata service points readers to a security blog post titled “Add defense in depth against open firewalls, reverse proxies, and SSRF vulnerabilities with enhancements to the EC2 Instance Metadata Service.” SSRF was not a side note in that incident. It was the entire opening move.

What You’ll Build

You’ll build, attack, and then fix a small “fetch this URL and show me a preview” feature, the same shape as a webhook tester or link-preview endpoint in a real product. Along the way you will:

  • Reproduce two real SSRF attacks against services running on your own machine: one that leaks data from an “internal only” admin panel, and one that steals fake credentials from a stand-in for a cloud metadata service.
  • Build IP-based validation using nothing but the Python standard library’s ipaddress module.
  • Reproduce a real bypass of that validation, a DNS rebinding race condition, and fix it by resolving a hostname exactly once and pinning the connection to that address.
  • Reproduce a second real bypass using an HTTP redirect, and fix it by re-validating every hop instead of trusting your HTTP client’s default redirect handling.
  • Write an automated pytest suite that locks all of this in.

Prerequisites

  • Python 3.10 or later (this tutorial was built and run on 3.13.14).
  • pip install fastapi uvicorn requests pytest (tested against fastapi 0.141.1, uvicorn 0.52.4, requests 2.34.2, pytest 9.1.1).
  • Comfort running a couple of local processes and making HTTP requests from a terminal. You do not need a cloud account of any kind; everything in this tutorial, including the “cloud metadata service,” runs on your own machine.

Step 1: Build the Feature That Will Become Vulnerable

Start with the naive version of a URL preview endpoint. This exact pattern shows up constantly in real products, and the bug is always the same shape: whatever URL the caller supplies goes straight into an HTTP client with no questions asked.

# preview_vulnerable.py
import requests
from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/fetch-preview")
def fetch_preview(url: str = Query(...)):
    resp = requests.get(url, timeout=5)
    return {
        "requested_url": url,
        "status_code": resp.status_code,
        "content_type": resp.headers.get("content-type"),
        "body_preview": resp.text[:500],
    }

Run it:

uvicorn preview_vulnerable:app --port 8800

A quick sanity check confirms it works the way you’d expect for a legitimate request:

$ curl "http://127.0.0.1:8800/fetch-preview?url=https://example.com"
{"requested_url":"https://example.com","status_code":200,"content_type":"text/html","body_preview":"<!doctype html><html lang=\"en\"><head><title>Example Domain</title>..."}

Step 2: Set Up Two Things This Feature Should Never Be Able to Reach

To make the attack concrete, you need two targets that represent the kinds of things a real internal network exposes.

An “Internal Only” Admin Service

Most companies run several services like this: metrics dashboards, health endpoints, debug panels. They skip authentication because “only our own servers can reach this network segment.” That assumption is exactly what SSRF breaks.

# internal_admin.py
from fastapi import FastAPI

app = FastAPI()

_SECRET_CUSTOMER_COUNT = 128_403


@app.get("/admin/stats")
def admin_stats():
    return {
        "service": "internal-admin",
        "total_customers": _SECRET_CUSTOMER_COUNT,
        "internal_db_host": "db-primary.internal.sxzcorp.local",
        "note": "this endpoint has no login screen because it was never meant to be reachable from outside",
    }

A Stand-In for a Cloud Metadata Service

This is a local mock, not the real thing: it does not run on the actual link-local address a cloud provider uses, and it does not talk to any real cloud account. It exists to demonstrate the mechanism safely. It deliberately mimics the older, unauthenticated-GET shape of AWS’s original instance metadata service (what AWS now calls IMDSv1), since that shape is exactly what made SSRF-to-credential-theft possible in the first place.

# metadata_mock.py
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse

app = FastAPI()


@app.get("/latest/meta-data/iam/security-credentials/")
def list_roles():
    return PlainTextResponse("sxz-app-role")


@app.get("/latest/meta-data/iam/security-credentials/sxz-app-role")
def role_credentials():
    return PlainTextResponse(
        '{"Code": "Success", "Type": "AWS-HMAC", '
        '"AccessKeyId": "ASIAFAKEEXAMPLE00000", '
        '"SecretAccessKey": "fakeSecretKeyDoNotUseThisIsADemo000000000000", '
        '"Token": "FAKE.TOKEN.FOR.DEMO.ONLY", '
        '"Expiration": "2026-09-20T00:00:00Z"}'
    )

Run both on their own ports:

uvicorn internal_admin:app --port 8801
uvicorn metadata_mock:app --port 8802

Step 3: Attack Your Own Feature

With all three services running, use the “innocent” preview endpoint to reach both targets. This is the entire attack: no exploit tooling, just a URL.

$ curl "http://127.0.0.1:8800/fetch-preview?url=http://127.0.0.1:8801/admin/stats"
{"requested_url":"http://127.0.0.1:8801/admin/stats","status_code":200,"content_type":"application/json","body_preview":"{\"service\":\"internal-admin\",\"total_customers\":128403,\"internal_db_host\":\"db-primary.internal.sxzcorp.local\",...}"}

$ curl -G "http://127.0.0.1:8800/fetch-preview" --data-urlencode "url=http://127.0.0.1:8802/latest/meta-data/iam/security-credentials/sxz-app-role"
{"requested_url":"http://127.0.0.1:8802/latest/meta-data/iam/security-credentials/sxz-app-role","status_code":200,"content_type":"text/plain; charset=utf-8","body_preview":"{\"Code\": \"Success\", \"Type\": \"AWS-HMAC\", \"AccessKeyId\": \"ASIAFAKEEXAMPLE00000\", \"SecretAccessKey\": \"fakeSecretKeyDoNotUseThisIsADemo000000000000\", ...}"}

Both attacks worked. The internal admin panel’s customer count and internal hostname leaked straight through the public preview form, and the “AWS credentials” from the metadata mock came back just as easily. This is the exact chain that made the Capital One breach possible: a URL-fetching feature with a network path to the metadata service, and no check on where it was being sent.

Step 4: Block Private, Loopback, and Link-Local Addresses

The fix starts with a policy: decide which resolved IP addresses are simply never acceptable destinations, then check every request against that policy before fetching anything. Python’s standard library ipaddress module already knows how to classify every range that matters here.

# ip_policy.py
import ipaddress


def is_blocked_ip(ip_str: str) -> bool:
    ip = ipaddress.ip_address(ip_str)
    return (
        ip.is_private
        or ip.is_loopback
        or ip.is_link_local
        or ip.is_reserved
        or ip.is_multicast
        or ip.is_unspecified
    )

Why is_private Alone Almost Covers Everything

Python’s own ipaddress module documentation defines is_private as “True if the address is defined as not globally reachable by iana-ipv4-special-registry (for IPv4) or iana-ipv6-special-registry (for IPv6),” and that IANA registry bundles RFC 1918 private ranges, loopback, and link-local addresses together. That means is_private alone already catches 169.254.169.254, the address AWS, Azure, and several other clouds use for their instance metadata service, because link-local addresses fall under that same “not globally reachable” umbrella. This code checks is_loopback, is_link_local, and the rest individually anyway, both so the block reason is legible in an error message and so the policy does not silently change behavior if a future Python release narrows what is_private covers.

A quick check confirms the ranges that matter are all caught:

>>> import ipaddress
>>> for t in ["127.0.0.1", "169.254.169.254", "10.0.0.5", "192.168.1.1", "8.8.8.8"]:
...     ip = ipaddress.ip_address(t)
...     print(t, ip.is_private, ip.is_loopback, ip.is_link_local)
...
127.0.0.1 True True False
169.254.169.254 True False True
10.0.0.5 True False False
192.168.1.1 True False False
8.8.8.8 False False False

Wire this into a fetcher that resolves the hostname, checks every resolved address, and only then makes the request:

# naive_safe_fetch.py
import socket
from urllib.parse import urlparse

import requests

from ip_policy import is_blocked_ip


def resolve_ips(hostname: str) -> list[str]:
    infos = socket.getaddrinfo(hostname, None)
    return sorted({info[4][0] for info in infos})


def naive_safe_fetch(url: str) -> requests.Response:
    hostname = urlparse(url).hostname
    if hostname is None:
        raise ValueError("URL has no hostname")

    ips = resolve_ips(hostname)
    blocked = [ip for ip in ips if is_blocked_ip(ip)]
    if blocked:
        raise ValueError(f"blocked: {hostname} resolves to blocked address(es) {blocked}")

    return requests.get(url, timeout=5)

Run the same two attacks again, this time against naive_safe_fetch directly:

>>> from naive_safe_fetch import naive_safe_fetch
>>> naive_safe_fetch("http://127.0.0.1:8801/admin/stats")
Traceback (most recent call last):
    ...
ValueError: blocked: 127.0.0.1 resolves to blocked address(es) ['127.0.0.1']

Both direct attacks are now blocked, and a legitimate request to http://example.com still goes through cleanly. It looks like the fix is done. It is not.

Step 5: The Gotcha That Breaks Naive Validation: DNS Rebinding

Look again at naive_safe_fetch. It resolves the hostname once, itself, to validate it. Then requests.get(url) resolves the same hostname a second time, independently, deep inside its own connection-handling code. Nothing guarantees those two lookups return the same answer. OWASP’s cheat sheet documents exactly this bypass in its “Bypassing restrictions” appendix, warning that DNS resolution “can be used by an attacker to bind a legit domain name to an internal IP address.” An attacker who controls the DNS server for a domain can serve a harmless-looking public IP address to the first query (the one your validation code sees) and a private IP address to every later query (the one your HTTP client actually connects to). This general technique is usually called DNS rebinding.

Reproducing It in a Lab, Safely

You do not need to control a real malicious DNS server to prove this bug exists. You need something that behaves like one for exactly one hostname, for the length of a test. A real rebinding DNS server can only control answers for the domain name it is authoritative for, so the safest, most accurate stand-in is one that does the same: pass through every lookup except a single hostname you designate, and flip that one hostname’s answer after the first call.

# rebinding_lab.py
import socket


class FlippingResolver:
    def __init__(self, hostname: str, public_ip: str, private_ip: str):
        self.hostname = hostname
        self.public_ip = public_ip
        self.private_ip = private_ip
        self.calls = 0
        self._real_getaddrinfo = socket.getaddrinfo

    def _fake_getaddrinfo(self, host, port, *args, **kwargs):
        if host != self.hostname:
            return self._real_getaddrinfo(host, port, *args, **kwargs)
        self.calls += 1
        ip = self.public_ip if self.calls == 1 else self.private_ip
        return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0))]

    def __enter__(self):
        socket.getaddrinfo = self._fake_getaddrinfo
        return self

    def __exit__(self, *exc):
        socket.getaddrinfo = self._real_getaddrinfo

A gotcha inside the gotcha: the first version of this lab did not check host != self.hostname at all; it flipped the answer for every lookup, regardless of what was being resolved. That is not how DNS actually works (a DNS server cannot rebind a literal IP address, only a name it is authoritative for), and it produced a false result later in this tutorial: even the fixed fetcher looked vulnerable, because the mock itself, not the code under test, was flipping answers it had no business touching. Scoping the mock to one hostname fixed it, and is a good reminder to double-check your own test doubles as critically as the code they are testing.

Now prove the bug:

# demo_rebinding.py
from naive_safe_fetch import naive_safe_fetch
from rebinding_lab import FlippingResolver

PUBLIC_IP = "172.66.147.243"  # example.com, resolved once for real just before this demo
url = "http://rebind-demo.sxz.test:8801/admin/stats"

with FlippingResolver(hostname="rebind-demo.sxz.test", public_ip=PUBLIC_IP, private_ip="127.0.0.1") as resolver:
    resp = naive_safe_fetch(url)
    print(f"resolver.calls = {resolver.calls}")
    print(f"status_code = {resp.status_code}")
    print(f"body = {resp.text}")
$ python demo_rebinding.py
resolver.calls = 2
status_code = 200
body = {"service":"internal-admin","total_customers":128403,"internal_db_host":"db-primary.internal.sxzcorp.local",...}

resolver.calls = 2 tells the whole story: the validation step (call 1) saw the harmless public IP and passed the request, and the actual connection (call 2) landed on 127.0.0.1 and leaked the internal admin data anyway. The IP-blocking logic from Step 4 never ran against the address that mattered.

Step 6: Fix It by Resolving Once and Pinning the Connection

The fix is to make sure there is only ever one DNS lookup for a given request, and that the lookup used to validate the destination is the exact same one used to connect to it. In practice that means resolving the hostname yourself, checking the result, and then connecting directly to that IP address, while still sending the original hostname in the Host header so the destination server sees a normal-looking request.

# safe_fetch.py
from urllib.parse import urlparse, urlunparse

import requests

from ip_policy import is_blocked_ip
from naive_safe_fetch import resolve_ips

ALLOWED_SCHEMES = {"http", "https"}
MAX_REDIRECTS = 5


class SSRFBlocked(ValueError):
    pass


def _validate_and_pin(url: str) -> tuple[str, str]:
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise SSRFBlocked(f"scheme not allowed: {parsed.scheme!r}")

    hostname = parsed.hostname
    if not hostname:
        raise SSRFBlocked("URL has no hostname")

    ips = resolve_ips(hostname)
    if not ips:
        raise SSRFBlocked(f"could not resolve {hostname!r}")

    blocked = [ip for ip in ips if is_blocked_ip(ip)]
    if blocked:
        raise SSRFBlocked(f"{hostname!r} resolves to blocked address(es): {blocked}")

    # Pin the connection to the exact IP we just validated. This is the
    # one and only DNS lookup this request will ever use.
    pinned_ip = ips[0]
    netloc = pinned_ip if not parsed.port else f"{pinned_ip}:{parsed.port}"
    pinned_url = urlunparse(parsed._replace(netloc=netloc))
    return pinned_url, hostname

Test it against the same DNS-rebinding lab, with a short timeout so the demo does not sit around waiting:

# demo_rebinding_fixed.py
import requests
from rebinding_lab import FlippingResolver
from safe_fetch import safe_fetch, SSRFBlocked

url = "http://rebind-demo.sxz.test:8801/admin/stats"

with FlippingResolver(hostname="rebind-demo.sxz.test", public_ip="172.66.147.243", private_ip="127.0.0.1") as resolver:
    try:
        r = safe_fetch(url, timeout=1)
        print(f"resolver.calls = {resolver.calls}")
        print(f"NOT BLOCKED (bug!): {r.status_code} {r.text[:100]}")
    except SSRFBlocked as e:
        print(f"resolver.calls = {resolver.calls}")
        print(f"blocked at validation: {e}")
    except requests.exceptions.RequestException as e:
        print(f"resolver.calls = {resolver.calls}")
        print(f"connection attempt used the VALIDATED ip, not the rebound one: {type(e).__name__}")
$ python demo_rebinding_fixed.py
resolver.calls = 1
connection attempt used the VALIDATED ip, not the rebound one: ConnectTimeout

resolver.calls is now 1. The fixed fetcher asked the rebinding DNS server exactly once, got the public IP, and used that same address for the real connection. Since nothing is listening on port 8801 at that public IP, the connection times out instead of quietly handing back the internal admin’s data. A safe failure (a timeout) instead of a silent leak is exactly what you want a fixed version to do.

Step 7: Redirects Are a Second Way Around Validation

Even a fetcher that pins its connection correctly can still be tricked if it blindly follows HTTP redirects, because requests follows redirects by default and does not re-run your validation on the new destination. OWASP’s cheat sheet makes this explicit: “Disable the support for the following of the redirection in your web client in order to prevent the bypass of the input validation.” Picture a URL that is genuinely public and passes every check, but responds with a 302 that points straight at an internal target:

# redirector.py
from fastapi import FastAPI
from fastapi.responses import RedirectResponse

app = FastAPI()


@app.get("/click-here")
def click_here():
    return RedirectResponse(url="http://127.0.0.1:8801/admin/stats", status_code=302)

With requests‘ default behavior (allow_redirects=True), the attack succeeds even though the URL you validated was perfectly innocent:

>>> import requests
>>> r = requests.get("http://127.0.0.1:8803/click-here", timeout=5)
>>> r.url
'http://127.0.0.1:8801/admin/stats'
>>> r.text
'{"service":"internal-admin","total_customers":128403,...}'

The fix is to turn redirects off by default and follow them manually, re-running the full validation on every hop:

# safe_fetch.py (continued)
def safe_fetch(url: str, _hop: int = 0, timeout: float = 5) -> requests.Response:
    if _hop > MAX_REDIRECTS:
        raise SSRFBlocked("too many redirects")

    pinned_url, hostname = _validate_and_pin(url)

    resp = requests.get(
        pinned_url,
        headers={"Host": hostname},
        timeout=timeout,
        allow_redirects=False,  # we re-validate every hop ourselves
    )

    if resp.is_redirect:
        location = resp.headers["Location"]
        next_url = requests.compat.urljoin(url, location)
        return safe_fetch(next_url, _hop=_hop + 1, timeout=timeout)

    return resp
>>> from safe_fetch import safe_fetch, SSRFBlocked
>>> safe_fetch("http://127.0.0.1:8803/click-here")
Traceback (most recent call last):
    ...
__main__.SSRFBlocked: '127.0.0.1' resolves to blocked address(es): ['127.0.0.1']

The redirect target is validated exactly like a top-level request, so it gets blocked the same way.

Step 8: Block Dangerous URL Schemes Too

OWASP’s flow diagram for SSRF notes that “in cases where the application itself performs the second request, it could use different protocols (e.g. FTP, SMB, SMTP, etc.) and schemes (e.g. file://, phar://, gopher://, data://, dict://, etc.)”. The scheme check in _validate_and_pin from Step 6 already handles this by only allowing http and https:

>>> safe_fetch("file:///etc/passwd")
Traceback (most recent call last):
    ...
__main__.SSRFBlocked: scheme not allowed: 'file'
>>> safe_fetch("gopher://127.0.0.1:6379/_FLUSHALL")
Traceback (most recent call last):
    ...
__main__.SSRFBlocked: scheme not allowed: 'gopher'

Step 9: Wire It Into the Real Feature

Swap the naive requests.get() call in the original endpoint for safe_fetch(), and translate a blocked request into a clean HTTP 400 instead of a raw exception:

# preview_fixed.py
from fastapi import FastAPI, HTTPException, Query

from safe_fetch import SSRFBlocked, safe_fetch

app = FastAPI()


@app.get("/fetch-preview")
def fetch_preview(url: str = Query(...)):
    try:
        resp = safe_fetch(url)
    except SSRFBlocked as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    return {
        "requested_url": url,
        "status_code": resp.status_code,
        "content_type": resp.headers.get("content-type"),
        "body_preview": resp.text[:500],
    }

Run it and repeat every attack from Step 3 against the real HTTP endpoint, not just the underlying function:

$ curl "http://127.0.0.1:8804/fetch-preview?url=http://example.com"
{"requested_url":"http://example.com","status_code":200,"content_type":"text/html","body_preview":"<!doctype html>...Example Domain..."}

$ curl "http://127.0.0.1:8804/fetch-preview?url=http://127.0.0.1:8801/admin/stats"
{"detail":"'127.0.0.1' resolves to blocked address(es): ['127.0.0.1']"}

$ curl "http://127.0.0.1:8804/fetch-preview?url=http://127.0.0.1:8803/click-here"
{"detail":"'127.0.0.1' resolves to blocked address(es): ['127.0.0.1']"}

$ curl -G "http://127.0.0.1:8804/fetch-preview" --data-urlencode "url=http://127.0.0.1:8802/latest/meta-data/iam/security-credentials/sxz-app-role"
{"detail":"'127.0.0.1' resolves to blocked address(es): ['127.0.0.1']"}

The legitimate request still works, and all three attacks (direct internal access, the redirect trick, and the metadata mock) come back as clean 400 responses instead of leaking anything.

Step 10: Automate Every Check With pytest

Manually re-running curl commands does not scale. Lock in every behavior demonstrated above with an automated test suite, including a test that documents the exact bug safe_fetch exists to close:

# test_safe_fetch.py
import ipaddress

import pytest

from ip_policy import is_blocked_ip
from naive_safe_fetch import naive_safe_fetch
from rebinding_lab import FlippingResolver
from safe_fetch import SSRFBlocked, safe_fetch

INTERNAL_ADMIN = "http://127.0.0.1:8801/admin/stats"
METADATA_MOCK = "http://127.0.0.1:8802/latest/meta-data/iam/security-credentials/sxz-app-role"
REDIRECTOR = "http://127.0.0.1:8803/click-here"
LEGIT_URL = "http://example.com"


@pytest.mark.parametrize(
    "ip_str,expected",
    [
        ("127.0.0.1", True),
        ("169.254.169.254", True),
        ("10.0.0.5", True),
        ("192.168.1.1", True),
        ("0.0.0.0", True),
        ("::1", True),
        ("8.8.8.8", False),
        ("172.66.147.243", False),
    ],
)
def test_is_blocked_ip(ip_str, expected):
    assert is_blocked_ip(ip_str) is expected


def test_safe_fetch_allows_legit_public_url():
    resp = safe_fetch(LEGIT_URL)
    assert resp.status_code == 200
    assert "Example Domain" in resp.text


def test_safe_fetch_blocks_direct_internal_admin():
    with pytest.raises(SSRFBlocked):
        safe_fetch(INTERNAL_ADMIN)


def test_safe_fetch_blocks_direct_metadata_mock():
    with pytest.raises(SSRFBlocked):
        safe_fetch(METADATA_MOCK)


def test_safe_fetch_blocks_redirect_to_internal():
    with pytest.raises(SSRFBlocked):
        safe_fetch(REDIRECTOR)


def test_safe_fetch_blocks_disallowed_schemes():
    with pytest.raises(SSRFBlocked):
        safe_fetch("file:///etc/passwd")
    with pytest.raises(SSRFBlocked):
        safe_fetch("gopher://127.0.0.1:6379/_FLUSHALL")


def test_naive_safe_fetch_is_vulnerable_to_dns_rebinding():
    """Documents the exact bug safe_fetch() exists to close."""
    url = "http://rebind-demo.sxz.test:8801/admin/stats"
    with FlippingResolver(hostname="rebind-demo.sxz.test", public_ip="172.66.147.243", private_ip="127.0.0.1"):
        resp = naive_safe_fetch(url)
    assert resp.status_code == 200
    assert "internal-admin" in resp.text  # leaked


def test_safe_fetch_resists_dns_rebinding():
    import requests

    url = "http://rebind-demo.sxz.test:8801/admin/stats"
    with FlippingResolver(hostname="rebind-demo.sxz.test", public_ip="172.66.147.243", private_ip="127.0.0.1") as resolver:
        with pytest.raises((SSRFBlocked, requests.exceptions.RequestException)):
            safe_fetch(url, timeout=1)
        assert resolver.calls == 1
$ pytest test_safe_fetch.py -v
...
test_safe_fetch.py::test_is_blocked_ip[127.0.0.1-True] PASSED
test_safe_fetch.py::test_is_blocked_ip[169.254.169.254-True] PASSED
test_safe_fetch.py::test_is_blocked_ip[10.0.0.5-True] PASSED
test_safe_fetch.py::test_is_blocked_ip[192.168.1.1-True] PASSED
test_safe_fetch.py::test_is_blocked_ip[0.0.0.0-True] PASSED
test_safe_fetch.py::test_is_blocked_ip[::1-True] PASSED
test_safe_fetch.py::test_is_blocked_ip[8.8.8.8-False] PASSED
test_safe_fetch.py::test_is_blocked_ip[172.66.147.243-False] PASSED
test_safe_fetch.py::test_safe_fetch_allows_legit_public_url PASSED
test_safe_fetch.py::test_safe_fetch_blocks_direct_internal_admin PASSED
test_safe_fetch.py::test_safe_fetch_blocks_direct_metadata_mock PASSED
test_safe_fetch.py::test_safe_fetch_blocks_redirect_to_internal PASSED
test_safe_fetch.py::test_safe_fetch_blocks_disallowed_schemes PASSED
test_safe_fetch.py::test_naive_safe_fetch_is_vulnerable_to_dns_rebinding PASSED
test_safe_fetch.py::test_safe_fetch_resists_dns_rebinding PASSED

15 passed in 1.26s

Common Mistakes and Gotchas

Blocklists Miss Things; Allowlists Don’t

Everything built above is a denylist of address ranges (loopback, RFC 1918, link-local, and so on), which is different from trying to denylist specific hostnames or domains. OWASP’s cheat sheet is blunt about why hostname-based blocklists fail: it recommends validating “using allowlists rather than blocklists” wherever the set of legitimate destinations is small and known ahead of time, precisely because attackers have far more creative ways to spell a hostname than defenders have patience to enumerate them.

Numeric IP Literals Are a Real, Platform-Dependent Bypass

A well-known SSRF bypass technique encodes an IP address in a form a naive string check would not recognize as “the same” address: as a plain 32-bit decimal integer (http://2130706433/ is 127.0.0.1), as hex, as octal, or with fewer than four dotted segments. Do not take that on faith, though: test it against the exact resolver your own deployment uses. On this tutorial’s Windows sandbox, none of those forms resolved at all:

>>> import socket
>>> socket.gethostbyname("2130706433")
Traceback (most recent call last):
    ...
socket.gaierror: [Errno 11001] getaddrinfo failed

That is a genuinely useful negative result, not a reason to skip the concern: it means this specific bypass does not reproduce on this specific OS resolver today, not that it can never work anywhere. Historically, glibc-based Linux resolvers have been more permissive about numeric literal forms than Windows is here. Because the code in this tutorial validates through ipaddress.ip_address(), which parses a small, well-defined set of canonical forms and rejects anything else, a raw literal like 169.254.169.254 is still caught correctly regardless of platform; it is hostname strings claiming to be numeric that need this extra caution. Test your own stack rather than assuming either result.

Pinning an IP Breaks HTTPS Unless You Handle SNI

Every demo in this tutorial used plain HTTP on purpose. Try pinning an HTTPS URL the same naive way (swap the hostname in the URL for its resolved IP) and TLS breaks immediately:

>>> import requests
>>> requests.get("https://172.66.147.243", headers={"Host": "example.com"}, timeout=5)
Traceback (most recent call last):
    ...
requests.exceptions.SSLError: ... SSLV3_ALERT_HANDSHAKE_FAILURE ...

TLS’s Server Name Indication (SNI) extension tells the remote server which certificate to present during the handshake itself, before any HTTP headers are ever sent, so simply swapping the hostname for its IP in the URL and hoping the Host header carries the intent does not work. Production-grade SSRF-safe HTTP clients handle this with a custom transport adapter that resolves the hostname, validates the IP, and then opens the TLS connection to that pinned IP while still setting server_hostname to the original name for SNI and certificate verification. That is a legitimately more involved piece of code than fits cleanly here; treat it as the next thing to build if your own vulnerable feature needs to fetch https:// URLs, and lean on the network-layer defense below in the meantime.

Application-Layer Checks Are Not Enough on Their Own

OWASP’s cheat sheet frames this as defense in depth for a reason: application code can have bugs, and a second, independent layer catches what the first one misses. The most effective second layer is a network-level one: firewall or security-group rules that simply do not allow the process running this kind of URL-fetching code to open connections to internal ranges or the metadata service at all, regardless of what the application logic decides. For cloud deployments specifically, migrate to a metadata service version that defends against exactly this attack pattern. AWS’s IMDSv2 requires callers to first send a PUT request to obtain a session token, and only accepts subsequent GET requests that carry it: “The token is required to access metadata using IMDSv2,” and by default the token’s own response has a network hop limit of 1, which stops it from being relayed through a simple proxy in the first place. A plain SSRF bug that can only make GET requests, which describes most of them, generally cannot perform that PUT-plus-custom-header handshake, so IMDSv2 closes off the exact technique used against Capital One even if an application-layer bug like the one in this tutorial still exists somewhere in the stack.

How to Verify It All Works End to End

  1. Start all four services: internal_admin.py (8801), metadata_mock.py (8802), redirector.py (8803), and preview_fixed.py (8804).
  2. Confirm a legitimate request still succeeds: curl "http://127.0.0.1:8804/fetch-preview?url=http://example.com" should return a 200 with real page content.
  3. Confirm all three attacks are blocked: the direct internal-admin URL, the redirector URL, and the metadata-mock URL should all return HTTP 400 with a clear detail message, never a 200 with leaked data.
  4. Run pytest test_safe_fetch.py -v and confirm all 15 tests pass, including the two DNS-rebinding tests, which prove the fix actually closes the gap the naive version left open rather than just moving it somewhere less obvious.

Next Steps

SSRF sits alongside SQL injection, XSS, and CSRF as one of the classic ways untrusted input crosses a trust boundary it was never supposed to cross. If you found this useful, these related tutorials build the same muscle for other boundaries:

  • How to Prevent SQL Injection in Python With Parameterized Queries
  • How to Stop XSS Attacks in a Python Web App With a Content Security Policy
  • How to Stop CSRF Attacks in a Python Web App With Synchronizer Tokens
  • How to Prevent Argument Injection in a Windows Custom URI Protocol Handler

Tags:

Application SecurityCloud SecurityPythonSSRF

Share

A cargo ship's silhouette distorted into an elongated shape by an atmospheric superior mirage above the horizon at sea
Previous Post

A Hallucinated Cargo Manifest Turns the Pentagon’s AI Push Into a Human-in-the-Loop Problem

A long hotel corridor with a row of near-identical numbered doors, evoking the mix-up between a fictional test target and a real company
Next Post

Google Confirms Gemini Hacked Three Companies, Then Called It ‘Mistaken Identity’

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
26 Sep
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
26 Sep
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
Trending
September 26, 2026
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
September 26, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
September 26, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel
September 26, 2026
Anthropic’s $11.6 Billion Akamai Deal Flips the Usual AI Financing Script

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026