Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · message by message A link preview that fetches from inside the network Step through one pasted link, from the box where the user types it to the internal service the app can reach and the user cannot.
A link preview that fetches from inside the network, step 6 of 6. Messages so far: User to Your app: POST /preview {url}; Your app to Internal service: GET /admin; Your app to User: 200 and a page title.Userpastes a linkYour apprefuses internal linksInternal serviceno login: network onlyPOST /preview {url}GET /admin200 and a page title

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 Why can a link pasted into a preview box reach a service that the user cannot reach from their own computer?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The request is made by your server, from a network position that internal services trust

    That is the whole weakness. Many internal services have no login because the network is their gate, and a fetching server stands inside that gate.

  2. Question 2 of 2 A feature fetches the URL but shows the user only "Fetched" or "Failed". What can the user still learn?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Whether a host answers, and how long the request took, from the outcome and the timing

    Hiding the response makes the weakness harder to use, not harmless. The fix is to not make the request.

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:

One address, ten spellings, through the URL class JavaScript · forms/url_forms.mjs
// 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 through Python's urlsplit Python · forms/urlsplit_hosts.py
# 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

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

  1. Question 1 of 2 In url_forms.mjs the test "starts with 127." misses http://2130706433/ as written. Why does the parsed host catch it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The URL class reads the decimal number as an IPv4 address and gives 127.0.0.1

    The client connects to what its parser made of the host. A test must look at that result, and at the address behind it, not at the characters that were typed.

  2. Question 2 of 2 A validator in Python reads the host of http://good.example\@other.example/ as other.example, and the client's URL class connects to good.example. What is this, and what do you do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A parser differential. Judge the URL with the parser the client uses, or refuse URLs that two parsers read differently

    The check is worth only as much as its agreement with the client. Parse once, with the client's parser, and judge the parts.

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:

Sixteen addresses, Python's answers and a guard's verdict Python · addresses/ipaddress_table.py
# 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

  1. Question 1 of 2 A filter refuses an address when ip.is_private is true. Which address of the table does it wrongly let through?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 100.64.0.1

    One property is not a filter. The guard asks for a global address that is neither multicast nor reserved, after unwrapping IPv4 addresses inside IPv6 ones.

  2. Question 2 of 2 Why does the guard refuse 64:ff9b::7f00:1, although Python reports is_global True for it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It takes the IPv4 address out of the NAT64 form, sees 127.0.0.1, and judges that address

    IPv4-mapped, 6to4 and NAT64 forms put an IPv4 address inside an IPv6 one. Judge the address inside.

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:

Interactive · message by message Two lookups of one name, and the gap between them Step through the weak check, which looks a name up twice, and the fixed one, which looks it up once.
Two lookups of one name, and the gap between them, step 6 of 6. Messages so far: Your app to DNS: lookup rebind.example; DNS to Your app: 203.0.113.20; Your app to DNS: lookup rebind.example; DNS to Your app: 10.0.0.8; Your app to Host connected to: connect 10.0.0.8.Your appconnects to what it judgedDNSanswers change per lookupHost connected tointernal addresslookup rebind.example203.0.113.20lookup rebind.example10.0.0.8connect 10.0.0.8

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 weak and a fixed preview fetcher on eleven URLs Python · preview/preview_guard.py
# 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

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

  1. Question 1 of 2 What makes the weak version fetch rebind.example from 10.0.0.8?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The check and the client each look the name up, and the second answer is internal

    The fixed version looks the name up once and connects to the address it judged, so there is no second answer to change.

  2. Question 2 of 2 dual.example has the IPv4 answer 203.0.113.30 and the IPv6 answer ::1. The weak version fetches from ::1. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It judged only the IPv4 answer, and the client tries IPv6 first

    Judge every address in the answer, because a client may use any of them. One address that is not public is enough to refuse the URL.

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:

Interactive · stage by stage One safe fetch, hop by hop Step through the checks of the fixed fetch, in order, and see where a redirect sends the request back to the start.
One safe fetch, hop by hop, step 8 of 8. Showing Parse the URL, Scheme, Is the host clear?, Resolve once, Is every address public?, Connect to that address, Is it a redirect?, Read within limits, Refuse and log. In focus: Read within limits.not allowednot publica new URLParse the URLthe client's own parserSchemehttps or httpIs the host clear?a name or a standard literalResolve oncethe A and AAAA recordsIs every address public?IPv4 inside IPv6 unwrappedConnect to that addressHost and TLS name keptIs it a redirect?it names a new URLRead within limitssizeRefuse and logone answer for every no

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 A fetcher judges the first URL, then lets its client follow redirects. What is missing?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Each redirect target is a new URL and needs every check again

    ASVS 15.3.2 asks backend requests not to follow redirects automatically. Follow them yourself, judge each one, and stop after a few.

  2. Question 2 of 2 A preview feature returns the upstream headers and body whole to the caller. What is the better design?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Read within a size and time limit and return only what the feature needs, such as the title

    OWASP's API list says not to send raw responses to clients. A size and a time limit on the read bound what one slow or huge response can cost the server.

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:

Interactive · answer and compare Which controls does your fetching feature need? Answer three questions about the feature. The table marks the controls that fit and says why the others need a partner.
Are the destinations known in advance?
Can the service reach internal systems that ask for no login?
Can you run an egress proxy or network rules for this service?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Six controls against the four things that decide what an outbound fetch needs
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

  1. Question 1 of 2 Why add egress rules when the code already judges every address?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Code has bugs and gaps, and a rule at the network judges the real connection even when the code is wrong

    The two layers fail differently, which is why you want both. The rule does not depend on the code being right.

  2. Question 2 of 2 Users paste any URL, and the service can reach internal systems that ask for no login. Which combination does the lesson favour?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A guard that resolves, judges and pins in the app, an egress proxy or network rules around it, and limits on the response

    The table of the section answers back to your own case. Pick the guard for user-chosen URLs and a control outside the app for the systems that trust the network.

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:

  1. The scheme is http or https, the URL has no user name or password, and the port is missing, 80 or 443.
  2. A host written as a number must be in the standard form that ipaddress.ip_address accepts. A host whose last label is all digits, or starts with 0x, and that is not such a literal, is refused (for example 2130706433 or 0x7f.1). Any other host is a name.
  3. 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 in LAB_HOSTS or it is global and neither multicast nor reserved.
  4. Fetch with client.get(url, connect_to=...), using the first address that was judged.
  5. 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.
  6. 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.

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.

IP Address Converter Type an address from your own logs, in any notation, and see every form a parser may read it as.

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.

  1. Question 1 of 18 What is SSRF?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: A server made to send a request to a destination chosen by its user, from the server's own network position

    The request comes from the server and its destination from the user. The server's position is what makes it valuable.

  2. Question 2 of 18 Where does OWASP's 2025 Top 10 place SSRF?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: Inside A01 Broken Access Control, which absorbed the category A10:2021

    The server uses its own position to do what the user may not, which is an access control failure.

  3. Question 3 of 18 A feature calls the webhook URLs that customers register. What makes it an SSRF risk?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: The customer chooses the URL, and the call is made from your network

    Webhooks, link previews, imports and image proxies all let a user choose an address that your server then contacts.

  4. Question 4 of 18 When does the WHATWG URL Standard read a host as an IPv4 address?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: When its last label is all digits, or is 0x followed by hex digits

    That is why 2130706433 and 0x7f.1 become 127.0.0.1 in the URL class and in the clients that follow it.

  5. Question 5 of 18 What does new URL("http://0x7f.1/").hostname give?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: 127.0.0.1

    The program's third row shows it. Judge the parsed host and the address behind it.

  6. Question 6 of 18 What does OWASP's SSRF sheet advise when two components parse the same URL and could disagree?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: Refuse the URL rather than try to make the parsers agree

    ASVS 1.5.3 asks parsers of the same data to agree. Where you cannot ensure it, refuse the URLs where they differ.

  7. Question 7 of 18 What does ip.is_global mean in Python's ipaddress module?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: The address is globally reachable according to the IANA special-purpose registries

    It is a property built from the registries, and the table shows its gaps, so use it with the other checks.

  8. Question 8 of 18 224.0.0.1 reports is_global True. What does the guard add to refuse it?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: A check that the address is not multicast

    Each extra condition closes one row of the table. The point is to list what you allow, not to hope one property covers it.

  9. Question 9 of 18 Which statement about ::ffff:10.0.0.8 is right?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: It is an IPv4-mapped address, Python answers as the IPv4 address inside it, and the guard unwraps it, so it is refused as private

    Unwrap the forms that carry an IPv4 address, then judge it. Otherwise the same address has two spellings and only one of them is refused.

  10. Question 10 of 18 What is DNS rebinding in this lesson?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: A name that answers differently on two lookups, so the checked address is not the one connected to

    An allowlist of names does not stop it, because the name stays the same while the address changes.

  11. Question 11 of 18 How many lookups of rebind.example does the fixed fetcher make in preview_guard.py?

    Type a number.

    A hint

    The fixed fetcher connects to the address it judged.

    Show the answer: Question 11 of 18

    Answer: 1

    One. The weak fetcher makes two, one in its check and one in its client, and the second answer is the internal one.

  12. Question 12 of 18 How do you make a client connect to the address you judged and still keep TLS and the Host header correct?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: Give the client the address to connect to, and keep the original name for the Host header and the certificate check

    Libraries offer a custom lookup or connection function for this, and an egress proxy does it for you.

  13. Question 13 of 18 Which schemes should a URL-fetching feature allow by default?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: http and https only

    Allow what the feature needs, and nothing else. For most features that is https, and http where the partner needs it.

  14. Question 14 of 18 What does ASVS 5.0.0 requirement 15.3.2 ask of backend requests to external URLs?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: That they do not follow redirects automatically, unless that is an intended feature

    Follow redirects yourself, send each target through the checks, and stop after a few hops.

  15. Question 15 of 18 A fetcher follows at most 3 redirects and judges each. A page is reached only after 4 redirects. What happens?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: The fetch is refused, because the fourth redirect is not followed

    A cap on hops keeps a chain of redirects from running forever and from hiding where the request ends up.

  16. Question 16 of 18 What is an egress control?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: A rule on what may leave your network or service, such as a proxy or a firewall allowlist

    Egress is the way out. Controlling it is what remains when the guard in the app has a hole.

  17. Question 17 of 18 Where should a fetching service be kept away from the cloud metadata address 169.254.169.254?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: In the guard and in the network rules, with the platform's own metadata hardening switched on as well

    The metadata address is in the link-local range, so the guard refuses it, and a network rule drops it as well.

  18. Question 18 of 18 You cannot run an egress proxy or network rules for this service. What follows?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: The guard in the app carries the whole defence, so test it with every case of the lesson and keep the reachable internal systems few

    Without a layer outside the code, every bug in the code is a hole. Test the guard hard, and reduce what it can reach.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

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

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

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

  8. 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_private alone misses shared space and NAT64.
  • Resolve once, judge every answer, and connect to the address you judged with the original name kept for the Host header and TLS; a second lookup is the gap that rebinding uses.
  • Treat a redirect as a new URL, allow only http and https on 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.