Security Engineering · Web Application Security
Server-Side Request Forgery and the Address the Server Can Reach
Why filtering a user-supplied URL is a parsing problem with a resolution step attached, and where DNS, redirects, and IPv6 forms defeat the check.
An application that fetches a URL on behalf of a user — a link preview, a webhook delivery, an image importer, an XML parser resolving external entities — is a proxy. The request originates from the server, so it carries the server’s network position: its firewall allowances, its credentials in environment variables, its access to the cloud metadata endpoint. Server-side request forgery is what happens when the user chooses the destination.
The control is a decision about which destinations are acceptable. The difficulty is that a URL is not the destination; it is a name that becomes a destination only after parsing and resolution, and an attacker controls both steps.
What has to be decided, and when
The naive check is a string comparison against a list: reject URLs containing 169.254.169.254, reject localhost, reject anything not starting with https://api.example.com. Every part of that fails.
RFC 3986 parsing is the first correction. The authority component of a URL can be written in ways that a substring search does not see: https://user@169.254.169.254/ has a userinfo part, and the host is what follows the @. https://example.com@attacker.example/ has a host of attacker.example and a userinfo of example.com. Backslash handling differs by parser: many URL parsers treat a backslash as a path separator in special schemes, so https://example.com\@attacker.example/ may parse with host attacker.example, while a checker that scanned for the first / would see example.com. Percent-encoding in the host is rejected by conformant parsers but tolerated by some. Unicode characters that normalise to dots under IDNA mapping can move a label boundary.
The correction is to use the platform’s URL parser, take the parsed host, and compare that. It is the only comparison that agrees with what the HTTP client will actually connect to — provided the client uses the same parser. A mismatch between the parser that validated and the parser that connected is the entire vulnerability in many implementations, and the standard remedy is to pass the parsed, re-serialised URL to the client rather than the original string.
The resolution step
Parsing yields a host. Connecting requires an address. Between them sits the resolver, and the resolver is under the attacker’s control if the hostname is theirs. An allowlist of hostnames does not restrict addresses; an allowlist of addresses requires resolving before deciding, and the name can resolve to different addresses on the next lookup.
A check performed at validation time is therefore checking a fact that may not hold at connect time. The classic exploitation is a hostname whose authoritative server answers with a public address when the validator resolves it and with a loopback address when the client resolves it — a class of behaviour usually called DNS rebinding, and it works because the two lookups are separate events.
The way out is not to trust any name and instead classify every address the connection is about to use, at the moment it is about to use it. Some HTTP client libraries expose a hook at connect time that receives the resolved address; others allow a custom connector or a custom socket factory. Where that hook exists, the check belongs there, applied to each address in the resolution result and repeated for every hop. Where it does not exist, the practical alternative is to resolve once, validate the resulting address, and connect to that address directly while sending the original hostname in the Host header and in TLS SNI — which keeps the name-based virtual hosting and certificate validation intact while removing the second lookup.
Classifying the address
The address ranges that must be refused are not a short list, and the standard registry in RFC 6890 enumerates them. The categories that matter:
- Loopback (
127.0.0.0/8and::1), which reaches services bound to the local host. - Link-local (
169.254.0.0/16andfe80::/10), which includes the cloud instance metadata service at169.254.169.254on many providers. - Private-use ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7), which reach the internal network. 0.0.0.0/8, which on many systems resolves to the local host.- Broadcast and reserved space, including
240.0.0.0/4. - IPv4-mapped IPv6 addresses (
::ffff:127.0.0.1), which are IPv6 on the wire and IPv4 to the service.
The mapped-address case is the one most often missed, because a classifier written for one family sees an address that does not match its rules. The correct handling is to convert mapped addresses to their IPv4 form before classification, and to classify both forms.
The ranges above are the ones a general-purpose web application must treat as internal. An application whose legitimate destinations are all on the public internet needs exactly this list. An application that must reach a fixed internal service needs a different design: an allowlist of specific hosts with no user control over the host at all, and the destination information supplied as a path or an identifier that the server maps to a URL.
import ipaddress
BLOCKED = [ipaddress.ip_network(n) for n in (
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
"198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4",
"::1/128", "fc00::/7", "fe80::/10", "ff00::/8",
)]
def address_is_public(addr: str) -> bool:
ip = ipaddress.ip_address(addr)
# An IPv4-mapped IPv6 address reaches the same host as the IPv4 address.
if ip.version == 6 and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
return not any(ip in net for net in BLOCKED)
The ipaddress module’s own is_private predicate is not a substitute: it also returns true for some ranges that are not internal, and its definition has changed across Python versions. An explicit list, written out in the code, is the version-independent form.
Redirects, schemes, and other transports
A client that follows redirects re-enters the whole problem at each hop. A permitted destination that answers 302 with a Location pointing at a loopback address defeats a check applied only to the initial URL. The redirect must be re-validated, which means either disabling automatic redirect following and implementing the loop with the check applied at each step, or using the connect-time hook described above, which applies to every connection regardless of how it was reached.
Scheme is a separate axis. Only http and https should reach an HTTP client; anything else has to be handled deliberately. file: reaches the filesystem through the same URL syntax, and a client that supports it has turned a URL fetch into an arbitrary file read. gopher:, dict:, ftp:, and ldap: are historical protocols whose presence in a URL parser is often accidental, and a request constructed for one of them can carry arbitrary bytes to a service that speaks a binary protocol. jar: and data: exist only in browser contexts and should be rejected on the server.
The XML external entity case is the same vulnerability with a different surface. An XML parser that resolves external entities performs a fetch specified by the document, and the document may be user-supplied. Disabling external entity resolution and DTD processing in the parser configuration is the direct control; a URL allowlist applied to entity system identifiers is the indirect one, and it is weaker because the parser decides when to resolve.
Why an outbound proxy is the cleaner boundary
Every check discussed so far is an in-process decision, and in-process decisions are one code path away from being skipped. A dedicated outbound proxy enforces the policy at the network layer: the application cannot open a connection except through the proxy, the proxy refuses destinations outside its policy, and it resolves and connects itself so that the application never sees the address. The connection egress rules on the host reinforce this — a default-deny outbound firewall that permits only the proxy’s address makes the bypass require a firewall change.
The cost is operational: a proxy is another component with its own capacity, its own logging, and its own configuration drift. The benefit is that the policy is enforced at a place the application cannot bypass by refactoring, and that the enforcement point sees the resolved address rather than a string the application chose to validate.
Response handling
A successful SSRF often returns data to the user. The response from an internal endpoint may be a JSON credential document, an administrative HTML page, or a binary protocol reply. An application that proxies the response body back to the client without inspecting it converts the SSRF into a data exfiltration channel, and one that logs the body writes internal data into its own logs.
The response-side controls are: cap the size, since an internal service may return an unbounded stream; do not follow the content type blindly, since the internal service may return HTML that the fetching application then renders; and do not include the body in an error path that reaches the user. For an image importer, the correct posture is to decode the bytes with an image library and re-encode them, rather than to store the fetched bytes and serve them with a content type derived from the remote server’s header — the same reasoning that governs header values assembled from untrusted parts.
The boundary that cannot be checked from inside
The recurring theme across these controls is that the deciding fact is an address, and the application only ever holds a name until the connection is made. The origin comparison the browser performs has a similar shape in the other direction: the browser canonicalises before comparing, and the server must do the same or the comparison disagrees with reality. Any security decision made on a string that a parser will later reinterpret belongs at the point after parsing, and in the SSRF case that point is the socket.