Web Security (OWASP Top 10:2025) Module 2 – A01:2025 Broken Access Control
SSRF and safe outbound requests
How a server that fetches a URL can be steered into your own network, and the defences that hold: allowlists, one lookup, redirect rules and egress limits.
What you will learn
- Explain server-side request forgery and why OWASP rolled it into A01:2025 Broken Access Control
- Choose a destination allowlist where destinations are known, and for any other URL resolve once, judge every address and connect to the address you judged
- Handle redirects, URL parser differences, IPv4 forms inside IPv6 addresses and DNS rebinding in a fetch guard
- Add egress controls and avoid returning a fetched response unprocessed
Before you start
What you’ll learn · Before you start
What you will learn
- Explain server-side request forgery and why OWASP rolled it into A01:2025 Broken Access Control
- Choose a destination allowlist where destinations are known, and for any other URL resolve once, judge every address and connect to the address you judged
- Handle redirects, URL parser differences, IPv4 forms inside IPv6 addresses and DNS rebinding in a fetch guard
- Add egress controls and avoid returning a fetched response unprocessed
Before you start
On this page
A link-preview box, a webhook setting and an import-from-URL button all do one thing: your server fetches an address that somebody else wrote, from inside your network. Of the eleven URLs that the program in section four gives to a fetcher that checks the text of the host, seven reach a place they should not.
That weakness is server-side request forgery. You will see why the parsed host decides where a URL leads, judge addresses with a library and not a prefix, resolve a name once and connect to the address you judged, treat a redirect as a new URL, and add the network rule that catches what code misses.
A server that fetches a URL fetches it from inside your network
A user pastes a link into a preview box, and your app fetches the page to show its title. The same shape sits behind webhook settings, imports from a URL, image proxies, PDF and screenshot makers and the discovery documents of sign-in providers. In each, a person chooses the address and your server makes the request. That is server-side request forgery (SSRF): A weakness in which a server makes a request to a destination chosen by its user, from the server's own network position. It can reach internal services that the user cannot, which is why OWASP files it under broken access control.. MITRE files it as CWE-918, it ranks 22nd in the 2025 CWE Top 25, and OWASP’s API list names it API7:2023. OWASP’s Top 10 gave it a category of its own in 2021, A10, and rolled it into A01 Broken Access Control in 2025: the server uses its own position to do what the user may not.
The position is the point. Your app runs next to services that only it can reach, and many of them ask for no login, because the network is their gate. A request made by your app arrives with that trust. Step through one preview:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A feature that fetches. A user pastes a link into a preview box. Your app fetches the page and shows its title. Webhooks, imports from a URL, image proxies and PDF or screenshot makers all start the same way, with a URL that someone else wrote.
- The app sits inside. Your app runs on a network with services that only it may reach. The internal service at 10.0.0.8 asks for no login, because the network is its only gate.
- A link that points inward. The pasted link names a host that resolves to the internal address. The user could never reach it from their own computer, but the request will be made with the position of your app, not the user's.
- The app fetches it. The app makes the request, because the feature says it should. The internal service sees a call from a trusted host and answers it as it would answer any colleague.
- The answer travels back. The app shows part of what it got. Even when it shows nothing, MDN notes that status codes and response times can tell the user something about the network behind your app.
- The fix refuses inward links. The fixed app judges the address behind the link before it connects, refuses internal ones and connects to the address it judged. A network rule on the app's side then stops what the code misses.
A feature that never returns the body can still leak. MDN lists the status code and the time a request took among the signals that tell a user something about the network behind your server. “We do not show the response” is therefore not a defence on its own.
Practise on code you own
The programs of this lesson use a fake resolver and a fake client and send nothing over a network. Test a fetching feature only on a copy of your own system, and never point such a test at a site you do not run. The first lesson of this track explains why that needs the owner’s written permission.
Checkpoint
Check: Why a fetching server is a way in
The parsed host decides where a URL leads, not the text you read
A URL can name one address in many ways, and the client that connects uses its own parser to decide which. The
WHATWG URL Standard, which browsers and Node.js follow, reads a host as an IPv4 address when its last label is all
digits or is 0x and hex digits. It accepts decimal, hex and octal parts and fewer than four parts, and it decodes
percent-encoded and some non-ASCII digits first, so one address has many spellings. The program writes the loopback
address ten ways and runs a text test, “starts with 127.”, on the host as written and on the host that the URL class
gives:
// The same address written ten ways, and a host written two ways that people misread, through the URL class that
// browsers and Node.js share (the WHATWG URL Standard). The point for a validator: the parsed host is what a client
// will connect to, and a test on the written host sees something else.
const written = (url) => url.replace(/^[a-z]+:\/\//, '').split('/')[0];
const looksLoopback = (host) => host.startsWith('127.');
const loopback = [
'http://127.0.0.1/',
'http://2130706433/',
'http://0x7f.1/',
'http://0177.0.0.1/',
'http://127.1/',
'http://127.0.0.1./',
'http://%31%32%37.0.0.1/',
'http://①②⑦.0.0.1/',
'http://[::ffff:127.0.0.1]/',
'http://[64:ff9b::7f00:1]/',
];
console.log("A text test for a host that starts with '127.', on the written host and on the host the URL class gives:");
console.log(`${'written host'.padEnd(20)}${'parsed host'.padEnd(20)}${'on written'.padEnd(12)}on parsed`);
for (const url of loopback) {
const host = new URL(url).hostname;
const before = looksLoopback(written(url)) ? 'catches' : 'misses';
const after = looksLoopback(host) ? 'catches' : 'misses';
console.log(`${written(url).padEnd(20)}${host.padEnd(20)}${before.padEnd(12)}${after}`);
}
console.log(`\n${'URL'.padEnd(38)}host the URL class uses`);
for (const url of ['http://localhost/', 'http://LOCALHOST./', 'http://good.example@other.example/', 'http://good.example\\@other.example/']) {
console.log(`${url.padEnd(38)}${new URL(url).hostname}`);
} Output
A text test for a host that starts with '127.', on the written host and on the host the URL class gives: written host parsed host on written on parsed 127.0.0.1 127.0.0.1 catches catches 2130706433 127.0.0.1 misses catches 0x7f.1 127.0.0.1 misses catches 0177.0.0.1 127.0.0.1 misses catches 127.1 127.0.0.1 catches catches 127.0.0.1. 127.0.0.1 catches catches %31%32%37.0.0.1 127.0.0.1 misses catches ①②⑦.0.0.1 127.0.0.1 misses catches [::ffff:127.0.0.1] [::ffff:7f00:1] misses misses [64:ff9b::7f00:1] [64:ff9b::7f00:1] misses misses URL host the URL class uses http://localhost/ localhost http://LOCALHOST./ localhost. http://good.example@other.example/ other.example http://good.example\@other.example/ good.example
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node url_forms.mjs
Five spellings escape the test on the written text, and the parsed host catches all five. The last two do not, because
the parser keeps the IPv6 forms as they are: [::ffff:7f00:1] is the loopback address inside an IPv4-mapped IPv6
address, and the other is the same address in the NAT64 form. A text test on the parsed host is better than one on the
written text, and it is still not a test of addresses. Section three fixes that.
The second half of the output shows names. LOCALHOST. becomes localhost. with its dot, so a test that compares the
host with localhost misses it. A user name before an @ is not the host, and a backslash ends the host in the URL
class. A second parser may disagree, and a validator is only as good as its agreement with the client that connects.
Python’s urlsplit reads the same four URLs this way:
# The same four URLs as the last rows of url_forms.mjs, read by Python's urlsplit instead of the WHATWG URL class.
# urlsplit does not follow the WHATWG URL Standard, and its documentation says that it does not validate its input.
from urllib.parse import urlsplit
urls = [
"http://localhost/",
"http://LOCALHOST./",
"http://good.example@other.example/",
"http://good.example\\@other.example/",
]
print(f"{'URL':<38}host urlsplit uses")
for url in urls:
print(f"{url:<38}{urlsplit(url).hostname}") Output
URL host urlsplit uses http://localhost/ localhost http://LOCALHOST./ localhost. http://good.example@other.example/ other.example http://good.example\@other.example/ other.example
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 urlsplit_hosts.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The last row is a parser differential: Two parsers reading the same input differently, such as a server's URL check and a browser disagreeing about which host a string names. A check is only as good as its agreement with the code that acts on it. More in “Open redirects and safe URL handling”: the URL class goes to good.example and
urlsplit reports other.example. A validator written with one and a client built on the other judge different hosts.
Python’s documentation says that urlsplit does not validate its input and does not follow the WHATWG standard, and
ASVS 5.0.0 requirement 1.5.3 asks parsers that handle the same data to read it the same way. OWASP’s SSRF sheet goes one
step further: when two components parse a URL and could disagree, refuse the URL.
Validating the string, then handing the string on
if "127." in url: refuse judges the text, and the client judges what its own parser makes of it. Parse once with the
parser the client uses, judge the result, and build the request from the parts you judged.
Checkpoint
Check: The parsed host, not the written text
Judge the address behind a name, with a library and not a prefix
Once the host is a name or an address in its standard form, the question is whether the address behind it is one you
are willing to reach. Python’s ipaddress module answers with properties that it builds from the IANA special-purpose
registries. The program prints them for 16 addresses, next to a guard that takes the IPv4 address out of any IPv6 form
that carries one, and then wants an address that is global and neither multicast nor reserved:
# What Python's ipaddress module says about 16 addresses, and the verdict of a guard built on it. An IPv6 address can
# carry an IPv4 address inside it (IPv4-mapped, NAT64 and 6to4 forms), so the guard first takes that address out, then
# wants one that is global and neither multicast nor reserved. The columns before the verdict are the module's own
# answers for the address as written.
import ipaddress
NAT64 = ipaddress.ip_network("64:ff9b::/96")
def embedded(ip):
"""The IPv4 address inside an IPv4-mapped, 6to4 or NAT64 address, or the address itself."""
if ip.version == 6:
if ip.ipv4_mapped:
return ip.ipv4_mapped
if ip.sixtofour:
return ip.sixtofour
if ip in NAT64:
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
return ip
def guard(text):
ip = embedded(ipaddress.ip_address(text))
return ip.is_global and not ip.is_multicast and not ip.is_reserved
rows = [
("127.0.0.1", "loopback"),
("10.0.0.8", "private (RFC 1918)"),
("192.168.1.20", "private (RFC 1918)"),
("169.254.169.254", "link-local, where cloud metadata lives"),
("100.64.0.1", "shared address space (carrier-grade NAT)"),
("0.0.0.0", "this host"),
("224.0.0.1", "multicast"),
("192.0.2.10", "documentation (RFC 5737)"),
("8.8.8.8", "an ordinary public address"),
("::1", "IPv6 loopback"),
("fe80::1", "IPv6 link-local"),
("fc00::1", "IPv6 unique local"),
("::ffff:127.0.0.1", "IPv4-mapped loopback"),
("::ffff:10.0.0.8", "IPv4-mapped private"),
("64:ff9b::7f00:1", "NAT64 form of 127.0.0.1"),
("2002:7f00:1::1", "6to4 form of 127.0.0.1"),
]
print(f"{'address':<20}{'is_private':<12}{'is_global':<11}{'multicast':<11}{'guard':<8}what it is")
for text, what in rows:
ip = ipaddress.ip_address(text)
print(f"{text:<20}{str(ip.is_private):<12}{str(ip.is_global):<11}{str(ip.is_multicast):<11}{'allow' if guard(text) else 'refuse':<8}{what}") Output
address is_private is_global multicast guard what it is 127.0.0.1 True False False refuse loopback 10.0.0.8 True False False refuse private (RFC 1918) 192.168.1.20 True False False refuse private (RFC 1918) 169.254.169.254 True False False refuse link-local, where cloud metadata lives 100.64.0.1 False False False refuse shared address space (carrier-grade NAT) 0.0.0.0 True False False refuse this host 224.0.0.1 False True True refuse multicast 192.0.2.10 True False False refuse documentation (RFC 5737) 8.8.8.8 False True False allow an ordinary public address ::1 True False False refuse IPv6 loopback fe80::1 True False False refuse IPv6 link-local fc00::1 True False False refuse IPv6 unique local ::ffff:127.0.0.1 True False False refuse IPv4-mapped loopback ::ffff:10.0.0.8 True False False refuse IPv4-mapped private 64:ff9b::7f00:1 False True False refuse NAT64 form of 127.0.0.1 2002:7f00:1::1 True False False refuse 6to4 form of 127.0.0.1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ipaddress_table.py
The properties alone are not a filter, and three rows show why. 100.64.0.1, shared address space for carrier-grade
NAT, is neither private nor global, so a test written as “not private” accepts it. 224.0.0.1 is global, because only
the multicast property knows it is not a place to connect. And the NAT64 form of 127.0.0.1 is global too: a filter
built on is_private or is_global alone lets that address through, where the guard unwraps it, sees the loopback
address inside and refuses. For IPv4-mapped addresses Python’s documentation says they answer as the IPv4 address inside
them. The 6to4 row has no such rule: Python lists the whole 2002::/16 range as private, so only the unwrapping in the
guard makes the verdict follow the address inside.
The minimum list in OWASP’s sheet, for the case where users choose the URL, names loopback, 0.0.0.0/8, the three
private ranges of RFC 1918, the metadata address 169.254.169.254, unique local fc00::/7, IPv6 link-local
fe80::/10 and multicast. The metadata address sits in the link-local range, and cloud platforms serve instance
metadata there, so a fetcher in the cloud must never reach it. The sheet calls a list of refused addresses
“bypass-prone”, prefers a list of allowed destinations wherever they are known, and keeps the refused list for the case
where users choose. A list of what is allowed means exact hosts, ports and schemes, which ASVS 1.3.6 asks for.
Key idea
Judge the address the client will connect to, after taking out the IPv4 address inside an IPv6 one, with a test for what is allowed and not a list of prefixes to refuse.
Checkpoint
Check: Judging addresses with a library
Resolve once, judge every answer, and connect to the address you judged
A guard that judges an address and then lets the HTTP client resolve the name again has a gap. Nothing makes two lookups of one name equal: the owner of a name can give it a very short lifetime and answer differently each time, which is called DNS rebinding: A name that answers differently on two lookups, for example a public address when a guard checks it and an internal address when the client connects. Resolving once and connecting to the judged address closes the gap.. OWASP’s sheet says an allowlist of names does not stop it. Step through the weak check and the fixed one:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The app checks the name. The weak app wants to know where rebind.example points before it fetches the page. It looks the name up once and keeps only its verdict.
- The first answer is public. The answer is the public address 203.0.113.20, so the check passes. The app believes the name is safe and hands the URL to its HTTP client.
- The client looks it up again. The HTTP client knows nothing of the app's verdict. Before it connects, it resolves the name itself, which is a second lookup of the same name.
- The second answer is internal. Nothing makes two answers equal. The owner of a name can give it a very short lifetime and answer differently each time, as this lab's fake DNS does. This time the answer is the internal address 10.0.0.8.
- The client connects inward. The check judged one address and the client used another. The gap between checking and connecting is the weakness, and it exists only because the name was resolved twice.
- The fixed app looks up once. The fixed app keeps the answer it judged, 203.0.113.20, and tells its client to connect to exactly that address, with the original name for the Host header and the TLS check. There is no second lookup left to change.
The fix has three parts. Resolve the name once and keep every answer, the A and the AAAA records. Judge all of them, because
a client may try any address of the answer, and IPv6 first. Then tell the client to connect to the address you judged,
while the original name still goes into the Host header and the TLS check. Most HTTP libraries allow this: Node.js
clients accept their own lookup function, and Python clients accept a custom connection or adapter. An egress proxy
does it for you. The program below runs a weak fetcher and the fixed one on 11 URLs, with a fake DNS that gives
rebind.example a second, internal answer and dual.example an IPv6 address next to its IPv4 one:
# A link-preview feature fetches a URL for the user. The weak version judges the text of the host and lets the client
# resolve the name again; the fixed version resolves once, judges every address and connects to the address it judged.
# There is no network: FakeDns answers from a table, one list of answers per lookup, and FakeClient records where it
# connected. The addresses 203.0.113.x are documentation addresses (RFC 5737) that stand in for public hosts. Python
# calls them private, so the lab names them in LAB_HOSTS; a real guard has no such line.
import ipaddress
import socket
from urllib.parse import urljoin, urlsplit
LAB_HOSTS = ipaddress.ip_network("203.0.113.0/24")
ZONE = {
"docs.partner.example": [["203.0.113.10"]],
"rebind.example": [["203.0.113.20"], ["10.0.0.8"]], # the second lookup gets an internal address
"dual.example": [["203.0.113.30", "::1"]], # an A record and an AAAA record
"redirector.example": [["203.0.113.40"]],
"intranet.example": [["10.0.0.8"]],
"localhost": [["127.0.0.1", "::1"]],
}
REDIRECTS = {"http://redirector.example/go": (302, "http://10.0.0.8/admin")}
class FakeDns:
def __init__(self):
self.calls = {}
def lookup(self, host):
n = self.calls.get(host, 0)
self.calls[host] = n + 1
answers = ZONE.get(host)
return list(answers[min(n, len(answers) - 1)]) if answers else []
def read_literal(host):
"""The address that a client reads out of a host written as a number: dotted, decimal, hex or octal, or IPv6."""
try:
return str(ipaddress.ip_address(host))
except ValueError:
pass
try:
return socket.inet_ntoa(socket.inet_aton(host))
except OSError:
return None
class FakeClient:
"""Connects to connect_to when given one. Otherwise it resolves the name itself, as clients do, and tries IPv6 first."""
def __init__(self, dns):
self.dns, self.connected = dns, []
def get(self, url, connect_to=None):
parts = urlsplit(url)
if parts.scheme == "file":
self.connected.append("the local file system")
return 200, None
if connect_to is None:
found = [read_literal(parts.hostname)] if read_literal(parts.hostname) else self.dns.lookup(parts.hostname)
connect_to = sorted(found, key=lambda a: ":" not in a)[0]
self.connected.append(connect_to)
return REDIRECTS.get(url, (200, None))
def weak_preview(url, dns, client):
host = urlsplit(url).hostname or ""
if host == "localhost" or host.startswith(("127.", "10.", "192.168.")):
return "refused: private host name"
first = next((a for a in dns.lookup(host) if ":" not in a), None) # one lookup, the first IPv4 answer
if first and ipaddress.ip_address(first).is_private and ipaddress.ip_address(first) not in LAB_HOSTS:
return "refused: private address"
for _ in range(5): # the client follows redirects
status, location = client.get(url)
if status not in (301, 302, 307, 308) or not location:
break
url = urljoin(url, location)
return "fetched from " + ", ".join(client.connected)
NAT64 = ipaddress.ip_network("64:ff9b::/96")
def embedded(ip):
"""The IPv4 address inside an IPv4-mapped, 6to4 or NAT64 address, or the address itself."""
if ip.version == 6:
if ip.ipv4_mapped:
return ip.ipv4_mapped
if ip.sixtofour:
return ip.sixtofour
if ip in NAT64:
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
return ip
def public(text):
ip = embedded(ipaddress.ip_address(text))
return ip in LAB_HOSTS or (ip.is_global and not ip.is_multicast and not ip.is_reserved)
def refusal(parts):
if parts.scheme not in ("http", "https"):
return "scheme"
if parts.username or parts.password:
return "user name in the URL"
if parts.port not in (None, 80, 443):
return "port"
host = parts.hostname or ""
last = host.rstrip(".").rsplit(".", 1)[-1].lower()
if ":" not in host and (last.isdigit() or last.startswith("0x")):
try:
ipaddress.IPv4Address(host) # only the standard dotted form of a number is accepted
except ValueError:
return "a host written as a number"
return None
def safe_preview(url, dns, client, hops=3):
for _ in range(hops + 1):
parts = urlsplit(url)
reason = refusal(parts)
if reason:
return f"refused: {reason}"
try:
addresses = [str(ipaddress.ip_address(parts.hostname))] # a literal in its standard form
except ValueError:
addresses = dns.lookup(parts.hostname) # the only lookup of this hop
if not addresses or not all(public(a) for a in addresses):
return "refused: an address that is not public"
status, location = client.get(url, connect_to=addresses[0])
if status not in (301, 302, 307, 308) or not location:
return "fetched from " + ", ".join(client.connected)
url = urljoin(url, location) # a redirect is a new URL: judge it from the start
return "refused: too many redirects"
urls = [
"https://docs.partner.example/page",
"http://10.0.0.8/admin",
"http://localhost/admin",
"http://intranet.example/",
"http://2130706433/",
"http://[::ffff:127.0.0.1]/",
"https://rebind.example/page",
"https://dual.example/",
"http://redirector.example/go",
"file:///app/settings.json",
"https://docs.partner.example:8080/",
]
print(f"{'URL':<38}{'weak version':<38}fixed version")
for url in urls:
dns = FakeDns()
weak = weak_preview(url, dns, FakeClient(dns))
dns = FakeDns()
fixed = safe_preview(url, dns, FakeClient(dns))
print(f"{url:<38}{weak:<38}{fixed}")
print()
for label, fetch in (("weak", weak_preview), ("fixed", safe_preview)):
dns = FakeDns()
fetch("https://rebind.example/page", dns, FakeClient(dns))
print(f"lookups of rebind.example, {label} version: {dns.calls['rebind.example']}") Output
URL weak version fixed version https://docs.partner.example/page fetched from 203.0.113.10 fetched from 203.0.113.10 http://10.0.0.8/admin refused: private host name refused: an address that is not public http://localhost/admin refused: private host name refused: an address that is not public http://intranet.example/ refused: private address refused: an address that is not public http://2130706433/ fetched from 127.0.0.1 refused: a host written as a number http://[::ffff:127.0.0.1]/ fetched from ::ffff:127.0.0.1 refused: an address that is not public https://rebind.example/page fetched from 10.0.0.8 fetched from 203.0.113.20 https://dual.example/ fetched from ::1 refused: an address that is not public http://redirector.example/go fetched from 203.0.113.40, 10.0.0.8 refused: an address that is not public file:///app/settings.json fetched from the local file system refused: scheme https://docs.partner.example:8080/ fetched from 203.0.113.10 refused: port lookups of rebind.example, weak version: 2 lookups of rebind.example, fixed version: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 preview_guard.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The weak fetcher refuses three URLs: the two internal addresses that it recognises by their text, and the internal
name that one IPv4 lookup shows. Of the other eight it fetches seven from places it should not: the decimal and mapped
loopback addresses, 10.0.0.8 after the second lookup of rebind.example, ::1 for dual.example because it judged
only the IPv4 answer, and, in the last three rows, a redirect, a local file and another port. The fixed fetcher fetches
the partner page from 203.0.113.10 and the rebinding page from 203.0.113.20, the address it judged, and refuses the
other nine with a reason. Its rebind.example fetch used one lookup, where the weak one used two, as the last two lines
of the output count.
Give the weak fetcher a second chance
Press Edit, change 10.0.0.8 in the rebind.example entry of ZONE to 203.0.113.21 and run the program again.
Both fetchers now fetch the page, from different addresses: the weak one from its second lookup, 203.0.113.21, and
the fixed one from the address it judged, 203.0.113.20.
Checkpoint
Check: One lookup and a connection to the judged address
A redirect is a new URL, and a response is data you must limit
Three rows of the table are not about addresses. A redirect, redirector.example/go, sends the client to
http://10.0.0.8/admin, and the weak fetcher followed it because HTTP clients do by default. ASVS 5.0.0 requirement
15.3.2 asks backend requests not to follow redirects automatically unless that is an intended feature, and MDN and OWASP
agree: follow redirects yourself, judge each new URL from the start, and stop after a few hops. The fixed fetcher in the
program allows three. The file: row is the scheme problem: allow http and https only, as ASVS 1.3.6 requires for
schemes, and MDN notes that https alone is likely enough for most applications. The last row is the port: unless a
feature documents another, accept 80 and 443 only.
Here is the whole fetch, hop by hop, with the redirect sending the request back to the start:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Parse the URL. The fetch starts by parsing the URL with the same parser the HTTP client uses. If two parsers could read the URL differently, the lesson's advice, and OWASP's, is to refuse it.
- Scheme, port, user name. Only http and https pass, on port 80 or 443, and a URL with a user name or password is refused. Anything else the feature needs is a reviewed exception.
- Is the host clear?. A host is a DNS name or a number in its standard form. A host written as a number in decimal, hex or octal, whatever the parser would turn it into, is refused rather than guessed at.
- Resolve once. The name is resolved a single time, and all the answers are kept, the IPv4 and the IPv6 ones. This is the only lookup of the hop.
- Is every address public?. Each answer is judged after the IPv4 address inside an IPv6 one is taken out. One non-public answer is enough to refuse, because the client may try any of them.
- Connect to that address. The client connects to the address that was judged, and only to it. The original name still goes into the Host header and the TLS check, so the server and the certificate see what they expect.
- A redirect starts over. A 301, 302, 307 or 308 names a new URL. Ordinary clients follow it without a word, which is why ASVS 15.3.2 asks backend requests not to follow redirects automatically. Here each new URL goes back to the start, at most three times.
- Read within limits. The body is read up to a size and a time limit, and its type is checked. The reply is not handed back to the caller whole, and the feature shows only what it needs, such as a title.
What comes back needs the same care. Read the body up to a size and a time limit, check its type, and never forward the caller’s cookies or credentials to the fetched site. Do not return the raw body or headers of the upstream response: OWASP’s API list says not to send raw responses to clients, and that turns a fetch that returns data into one that, at worst, reveals status and timing. A preview needs a title, so return a title. Log every outbound request, with the URL and the address it connected to, because a fetch that went somewhere unexpected is easier to see in a log than in a test.
Checkpoint
Check: Redirects, schemes and responses
Network rules stop what the code misses
Every check above is code in the app, and code has bugs. OWASP’s sheet therefore asks for the network layer as well: the application’s outbound reach is limited to the routes it needs, with a firewall or with network segregation. The Top 10 page of 2021 begins its network-layer advice with isolating URL-fetching features, with default-deny firewall rules and logging of the traffic. ASVS 5.0.0 requirements 13.2.4 and 13.2.5 ask for an allowlist of the systems the app may contact, enforced in the app, the web server, a firewall or several of them, and these egress control: A rule on what may leave a service or a network, such as an egress proxy or a firewall allowlist. It judges the real connection, so it still holds when the application's own checks have a bug. are what remain when the guard has a mistake.
Three placements are common. A fetcher service runs the feature apart from the systems that ask for no login, with rules that let it out to the internet and nowhere inside. An egress proxy is the only way out: the app asks the proxy to fetch, and the proxy resolves, judges and connects with rules of its own. Network rules at the host or the subnet drop connections to internal ranges and to the metadata address whatever the code does. For cloud metadata, follow your platform’s own hardening guidance; OWASP’s sheet names moving to the second version of AWS’s metadata service and disabling the first as an example. The table below puts six controls side by side:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Control | Fits known destinations | Fits any user URL | Closes check-to-connect | Survives a bug in the app |
|---|---|---|---|---|
| Destination allowlist Decides by: exact hosts, ports and schemes | Good only listed hosts can be named: the partner row was fetched, 10.0.0.8 was not | Poor users choose the destination, so there is no list to write | Mixed a listed name can still be rebound, so judge its addresses too | Poor one loose comparison and the list lets everything through |
| Resolve, judge, pin Decides by: every address, then that address | Mixed it works, but a plain list is simpler and stronger here | Good it judges whatever the user names, one address at a time | Good one lookup, and the connection uses the judged address | Poor it is code in the app, so a mistake in it is a hole |
| Deny private ranges Decides by: a list of addresses to refuse | Poor it lists what to refuse, where known hosts call for what to allow | Mixed the usual choice, but 100.64.0.1 and the mapped forms slip through a short list | Poor it judges a name or one lookup, not the connection | Poor a missing range is a hole |
| Egress proxy Decides by: one gateway with its own rules | Good one place to list the destinations for every service | Good it judges each request outside the app, in one place | Good the proxy resolves and connects itself | Good a bug in the app cannot reach what the proxy refuses |
| Network egress rules Decides by: what the network lets leave | Good the network lets out only the listed destinations | Mixed it keeps internal ranges closed, whatever the app thinks | Good it judges the real connection, after every lookup | Good it holds even when the code is wrong |
| Limit the response Decides by: size, time, type and what is returned | Mixed it bounds what a bad request can bring back | Mixed it bounds the damage of any URL, but it stops none | Poor the request has already been made | Mixed a size and time limit hold even when a check fails |
How the answers decide
- “Are the destinations known in advance?” Yes, a fixed list of hosts: rules out Deny private ranges and adds a caveat to Resolve, judge, pin. Known destinations call for a list of what is allowed. A list of what is refused is always incomplete, and a guard adds code where a list needs none.
- “Are the destinations known in advance?” No, users choose the URL: rules out Destination allowlist. Destinations that users choose cannot be listed. Judge each address and connect to the one you judged.
- “Can the service reach internal systems that ask for no login?” Yes, or I am not sure: adds a caveat to Resolve, judge, pin and Deny private ranges. With internal systems that ask for no login, one missed range or one rebound name is enough. Add a control outside the app.
- “Can you run an egress proxy or network rules for this service?” No, only the app code: rules out Egress proxy and Network egress rules. You cannot run them, so the guard in the app carries the whole defence. Test it with every row of this lesson.
In short: use a list of allowed destinations when you know them, a judged and pinned connection when users choose the URL, a proxy or network rules around both, and limits on what comes back.
Checkpoint
Check: Network rules around the code
Practise: a preview that fetches only from addresses it judged
Write the fetch guard of this lesson with the fake resolver and the fake client the tests provide. The sample tests include a literal in each non-public range, the numeric spellings, an answer with one internal address among several, a name that changes its answer, and a chain of redirects:
Exercise · Hard · Python
Fetch a preview only from addresses you judged
fetch_preview(url, dns, client) returns the body of the page at url, or None when the fetch must be refused. There is no network: dns.lookup(host) returns the list of addresses of a name, one answer per call, and client.get(url, connect_to=address) fetches without a lookup of its own and returns an object with status, location and body. Apply these rules to every URL, in this order:
- The scheme is
httporhttps, the URL has no user name or password, and the port is missing, 80 or 443. - A host written as a number must be in the standard form that
ipaddress.ip_addressaccepts. A host whose last label is all digits, or starts with0x, and that is not such a literal, is refused (for example2130706433or0x7f.1). Any other host is a name. - Resolve a name once, with
dns.lookup. A literal needs no lookup. Refuse when there are no addresses, or when any address is not public. An address is public when, after taking out the IPv4 address inside an IPv4-mapped, 6to4 or NAT64 (64:ff9b::/96) address, it is inLAB_HOSTSor it is global and neither multicast nor reserved. - Fetch with
client.get(url, connect_to=...), using the first address that was judged. - For the status 301, 302, 307 or 308 with a
location, join the location with the URL (urljoin) and apply every rule to the new URL. At most 3 redirects are followed: a fourth is refused. - Otherwise return the body.
LAB_HOSTS holds the documentation addresses that the tests use as public hosts, because Python does not call them global. A real guard has no such line. The starter refuses only localhost and 127.0.0.1 and lets the client look names up for itself.
Starter code · preview.py
import ipaddress
from urllib.parse import urljoin, urlsplit
LAB_HOSTS = ipaddress.ip_network("203.0.113.0/24")
NAT64 = ipaddress.ip_network("64:ff9b::/96")
def fetch_preview(url, dns, client):
"""The body of the page at url, or None when the fetch must be refused."""
host = urlsplit(url).hostname or ""
if host in ("localhost", "127.0.0.1"):
return None
return client.get(url).body The sample tests · test_preview.py
from types import SimpleNamespace
from preview import fetch_preview
class Dns:
"""One list of answers per lookup of a name: the n-th lookup gets the n-th list, and the last list repeats."""
def __init__(self, zone):
self.zone, self.calls = zone, []
def lookup(self, host):
self.calls.append(host)
answers = self.zone.get(host)
if not answers:
return []
return list(answers[min(self.calls.count(host) - 1, len(answers) - 1)])
class Client:
"""Serves pages from a table and records every (url, connect_to) it was asked for."""
def __init__(self, pages):
self.pages, self.asked = pages, []
def get(self, url, connect_to=None):
self.asked.append((url, connect_to))
return self.pages[url]
def page(body="hello"):
return SimpleNamespace(status=200, location=None, body=body)
def redirect(location, status=302):
return SimpleNamespace(status=status, location=location, body="")
ZONE = {
"docs.example": [["203.0.113.10"]],
"rebind.example": [["203.0.113.20"], ["10.0.0.8"]],
"dual.example": [["203.0.113.30", "::1"]],
"inside.example": [["10.0.0.8"]],
}
def run(url, pages=None, zone=ZONE):
dns, client = Dns(zone), Client(pages if pages is not None else {url: page()})
return fetch_preview(url, dns, client), dns, client
def test_a_public_page_is_fetched_from_the_address_that_was_checked():
body, dns, client = run("https://docs.example/start")
assert body == "hello"
assert client.asked == [("https://docs.example/start", "203.0.113.10")]
def test_scheme_port_and_user_name():
for url in ["file:///app/settings.json", "ftp://docs.example/", "gopher://docs.example/", "https://docs.example:8080/",
"https://docs.example:22/", "https://docs.example:abc/", "https://docs.example:99999/",
"https://user:secret@docs.example/", "https://user@docs.example/"]:
assert run(url)[0] is None, url
assert run("http://docs.example/")[0] == "hello"
assert run("https://docs.example:443/")[0] == "hello"
def test_literal_addresses_that_are_not_public():
for host in ["127.0.0.1", "10.0.0.8", "172.16.4.2", "192.168.1.20", "169.254.169.254", "100.64.0.1", "0.0.0.0",
"224.0.0.1", "[::1]", "[fe80::1]", "[fc00::1]", "[::ffff:127.0.0.1]", "[::ffff:10.0.0.8]",
"[64:ff9b::7f00:1]", "[2002:7f00:1::1]"]:
body, dns, client = run(f"http://{host}/")
assert body is None, host
assert client.asked == [], host
def test_an_ipv6_form_is_judged_by_the_address_inside_it():
for host in ["[::ffff:203.0.113.55]", "[64:ff9b::cb00:7137]", "[2002:cb00:7137::1]"]:
body, dns, client = run(f"http://{host}/")
assert body == "hello", host
assert len(client.asked) == 1 and dns.calls == [], host
def test_a_global_address_is_public():
body, dns, client = run("http://8.8.8.8/")
assert body == "hello"
assert client.asked == [("http://8.8.8.8/", "8.8.8.8")]
def test_a_public_literal_needs_no_lookup():
body, dns, client = run("http://203.0.113.55/")
assert body == "hello"
assert dns.calls == []
assert client.asked == [("http://203.0.113.55/", "203.0.113.55")]
def test_hosts_written_as_numbers_are_refused():
for host in ["2130706433", "0x7f.1", "127.1", "0177.0.0.1", "0x7f000001"]:
body, dns, client = run(f"http://{host}/")
assert body is None, host
assert client.asked == [], host
def test_every_answer_has_to_be_public():
assert run("https://dual.example/")[0] is None
assert run("https://inside.example/")[0] is None
assert run("https://unknown.example/")[0] is None, "a name with no answer"
def test_a_name_is_looked_up_once_and_the_checked_address_is_used():
body, dns, client = run("https://rebind.example/start")
assert body == "hello"
assert dns.calls == ["rebind.example"]
assert client.asked == [("https://rebind.example/start", "203.0.113.20")]
def test_redirects_are_judged_like_any_other_url():
pages = {
"https://docs.example/old": redirect("/new"),
"https://docs.example/new": page("moved"),
"https://docs.example/inward": redirect("http://10.0.0.8/admin"),
"https://docs.example/named": redirect("https://inside.example/", 301),
}
assert run("https://docs.example/old", pages)[0] == "moved"
body, dns, client = run("https://docs.example/inward", pages)
assert body is None
assert client.asked == [("https://docs.example/inward", "203.0.113.10")]
assert run("https://docs.example/named", pages)[0] is None
def test_at_most_three_redirects_are_followed():
def chain(n):
pages = {f"https://docs.example/{i}": redirect(f"/{i + 1}") for i in range(n)}
pages[f"https://docs.example/{n}"] = page("end")
return pages
assert run("https://docs.example/0", chain(3))[0] == "end"
assert run("https://docs.example/0", chain(4))[0] is None A hint
Write three small helpers first: one that takes the IPv4 address out of a mapped, 6to4 or NAT64 address, one that says whether an address is public, and one that returns a reason to refuse for the scheme, the port, the user name and the host written as a number. Then loop over the hops: refuse, resolve once, check every address, call client.get with connect_to, and go round again on a redirect, at most 4 requests in all.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then finish with the self-check. It takes one idea at a time, begins with the one you know least, and explains every answer as you give it:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is SSRF, and how do you prevent it?
Server-side request forgery makes a server send a request to a destination that its user chose, from the server’s own
position in the network. Features that fetch a URL, such as link previews, webhooks, imports and image proxies, are the
usual places, and MITRE files the weakness as CWE-918. Prevent it with a list of allowed destinations when you know
them. When users choose the URL, parse it with the parser the client uses, allow only http and https on ports 80 and 443,
resolve the name once, refuse if any address is not public after unwrapping IPv4 addresses inside IPv6 ones, and connect
to the address you judged. Handle redirects yourself, limit what comes back and do not return it raw. Add network rules
so that the fetching service cannot reach internal systems at all.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Customers register webhook URLs, and a worker posts events to them. Design the delivery path so that a customer cannot make the worker call your internal services, and say what you log.
-
Senior to staff
A teammate fixed a link-preview SSRF like this: resolve the host with the system resolver, reject private addresses, then pass the original URL to the HTTP library. A scan still reaches an internal service now and then. Where can it get through, and what do you change?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is SSRF, and which features are the usual places for it?
Server-side request forgery: your server fetches an address that a user chose, from inside your network, where services that ask for no login trust its position. Link previews, webhooks, imports from a URL, image proxies, PDF makers and sign-in discovery documents are typical. It is CWE-918 and API7:2023, and OWASP folded it into A01 Broken Access Control in 2025.
-
If a fetch feature never shows the response, is it safe?
No. The status code and the time a request took tell a user about the network behind your server. Do not return the raw body or headers of the upstream response either; a preview needs a title, so return a title.
-
Why must a URL guard judge the parsed host, and what pitfall remains?
One address has many spellings (decimal, hex, octal, fewer than four parts, percent-encoded digits, IPv6 mapped forms), and the client decides with its own parser. Parse once with the parser the client uses and build the request from the parts you judged. When two parsers could disagree about a URL, refuse it.
-
Why is "not private" a poor address test?
Shared carrier-grade NAT space (100.64.0.1) is neither private nor global, multicast 224.0.0.1 is global, and the NAT64 form of 127.0.0.1 is global too. Take out the IPv4 address inside any IPv6 form, then require an address that is global and neither multicast nor reserved. Prefer a list of allowed hosts, ports and schemes wherever the destinations are known.
-
Which addresses does OWASP's minimum refusal list name, and why the metadata address?
Loopback, 0.0.0.0/8, the three private ranges of RFC 1918, 169.254.169.254 (the metadata address, in the IPv4 link-local range), IPv6 unique local fc00::/7 and link-local fe80::/10, and multicast. Cloud platforms serve instance metadata on that address, so a fetcher in the cloud must never reach it. The sheet calls a list of refused addresses bypass-prone.
-
What is DNS rebinding, and how does a fetcher defeat it?
The owner of a name gives it a very short lifetime and answers differently on each lookup, so a guard that judges one answer and lets the client resolve again is fooled. Resolve once, keep every A and AAAA answer, judge all of them, and connect to the address you judged, while the original name still goes in the Host header and the TLS check.
-
How should a fetcher treat redirects, schemes and ports?
A redirect is a new URL: follow it yourself, judge it from the start and stop after a few hops (ASVS 5.0.0 15.3.2). Allow http and https only, ports 80 and 443 unless a feature documents another, limit the size and time of the body, and never forward the caller's cookies.
-
What remains when the guard in the code has a mistake?
Egress controls: a fetcher service that reaches the internet but nothing inside, an egress proxy as the only way out, or network rules that drop connections to internal ranges and the metadata address. ASVS 5.0.0 13.2.4 and 13.2.5 ask for an allowlist of the systems the app may contact.
Key takeaways
- A feature that fetches a URL makes the request from your network position, so a user’s link can reach services that trust the network; hiding the response is not a defence, because status and timing leak.
- The parsed host decides where a URL leads: one address has many spellings, parsers can disagree, and a test on the written text judges something the client does not connect to.
- Judge the address, not the name: take out the IPv4 address inside mapped, 6to4 and NAT64 forms, and require a global
address that is neither multicast nor reserved, because
is_privatealone misses shared space and NAT64. - Resolve once, judge every answer, and connect to the address you judged with the original name kept for the
Hostheader and TLS; a second lookup is the gap that rebinding uses. - Treat a redirect as a new URL, allow only
httpandhttpson ports 80 and 443, limit and process responses, and put egress rules around the service for the mistakes the code will make.
Terms in this lesson
- DNS rebinding
- A name that answers differently on two lookups, for example a public address when a guard checks it and an internal address when the client connects. Resolving once and connecting to the judged address closes the gap.
- egress control
- A rule on what may leave a service or a network, such as an egress proxy or a firewall allowlist. It judges the real connection, so it still holds when the application's own checks have a bug.
- server-side request forgery (SSRF)
- A weakness in which a server makes a request to a destination chosen by its user, from the server's own network position. It can reach internal services that the user cannot, which is why OWASP files it under broken access control.
References
- A01:2025 Broken Access Control (mapped CWEs, including CWE-918) (OWASP Foundation)
- Introduction: what changed in the Top 10 for 2025 (OWASP Foundation)
- A10:2021 Server-Side Request Forgery (SSRF) (OWASP Foundation)
- Server-Side Request Forgery Prevention Cheat Sheet (OWASP Foundation)
- API7:2023 Server Side Request Forgery (OWASP Foundation)
- Server-side request forgery (SSRF) (MDN Web Docs (Mozilla))
- CWE-918: Server-Side Request Forgery (SSRF) (MITRE)
- ipaddress: IPv4/IPv6 manipulation library (Python Software Foundation)
- urllib.parse: URL parsing (Python Software Foundation)
- URL Standard: host parsing and the IPv4 parser (WHATWG)
- IANA IPv4 Special-Purpose Address Registry (IANA)
- IANA IPv6 Special-Purpose Address Registry (IANA)
- ASVS 5.0.0, V1 Encoding and Sanitization (1.3.6 and 1.5.3) (OWASP Foundation)
- ASVS 5.0.0, V13 Configuration (13.2.4 and 13.2.5) (OWASP Foundation)
- ASVS 5.0.0, V15 Secure Coding and Architecture (15.3.2) (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress