CWE-918: Server-Side Request Forgery (SSRF)
Overview
Server-Side Request Forgery (SSRF) occurs when an application fetches a remote resource from a user-supplied URL without validating the destination. An attacker who controls that URL makes the server issue requests on their behalf, reaching internal services, cloud metadata endpoints, and anything else the server's network position allows.
Relationship to Other CWEs
CWE-918 is MITRE's Base-level child of CWE-441 (Unintended Proxy or Intermediary - 'Confused Deputy'), which is the general shape: a component with more authority than its caller acts on the caller's behalf without carrying the caller's identity with it. SSRF is that shape where the forwarded thing is an outbound network request and the borrowed authority is the server's network position. This page is the one to use for any finding about a user-supplied URL being fetched - CWE-441 is a Class and covers the cases where the deputy is an internal component rather than an HTTP client.
OWASP Classification
A01:2025 - Broken Access Control
Risk
High to Critical: Attackers can reach internal services that firewalls and access controls keep off the internet, scan internal networks, exfiltrate data, or make the server issue denial-of-service traffic. The metadata endpoint is the sharpest of these in a cloud environment, because it hands out credentials for the role attached to the instance (AWS, Azure, GCP).
Why This Is Hard to Get Right
The procedure is in Remediation Steps below. Two mechanisms account for most
SSRF defences that look correct and are not.
The first is DNS rebinding, which works like this:
- Create a domain that initially resolves to a legitimate IP
- Application validates the IP (passes allowlist)
- Attacker changes DNS to point to internal IP (127.0.0.1, 192.168.x.x)
- Application makes request using cached DNS or re-resolves
- Request goes to internal service
Defending against it takes three steps, in this order:
- Validate the hostname against the allowlist before resolving anything.
- Resolve the hostname and check every returned address, not just the first - an attacker can return one public and one private address from the same lookup.
- Connect to the address you validated, rather than letting the HTTP client resolve the name again. Validating and then handing the URL to a client that performs its own lookup leaves the race open, which is the single most common way this defense is built wrong.
Step 3 is the one with no portable formulation: it depends on whether your HTTP client exposes a connection hook, a custom transport, or a resolver override. The language pages carry a working version for each ecosystem.
The second is URL parsing. Parsers really do disagree, but not where it is
usually claimed. Every parser tested - Python urllib.parse, Go net/url,
Java java.net.URI, Node's WHATWG URL - agrees that
http://127.0.0.1@evil.com/ has the host evil.com. That URL is worth knowing
for the opposite reason: it goes outward to evil.com, so it defeats an
allowlist that searches the URL string for an approved host rather than parsing
it. It is not a route to internal services.
The genuine divergence is in how much a parser normalizes the host:
| URL host | Python | Go | Java | Node |
|---|---|---|---|---|
[::ffff:127.0.0.1] |
::ffff:127.0.0.1 |
::ffff:127.0.0.1 |
[::ffff:127.0.0.1] |
[::ffff:7f00:1] |
0x7f.0x0.0x0.0x1 |
unchanged | unchanged | null | 127.0.0.1 |
2130706433 |
unchanged | unchanged | unchanged | 127.0.0.1 |
127.1 |
unchanged | unchanged | null | 127.0.0.1 |
0 |
unchanged | unchanged | unchanged | 0.0.0.0 |
Two things follow, and they pull in opposite directions:
- Node normalizes, so a blocklist of strings fails:
[::ffff:7f00:1]and127.0.0.1are the same address, and a check comparing text sees two different hosts. Java returns a null host for some forms, so code readinggetHost()getsnulland a guard that treats null as "nothing to check" passes it on. - Python and Go do not normalize, which is the more dangerous half. The host stays an opaque string, so the usual guard - parse it as an IP address, and if that fails assume it is a hostname - reaches the wrong conclusion:
0x7f.0x0.0x0.0x1 ipaddress: rejected -> guard says "hostname" inet_aton -> 127.0.0.1
2130706433 ipaddress: rejected -> guard says "hostname" inet_aton -> 127.0.0.1
127.1 ipaddress: rejected -> guard says "hostname" inet_aton -> 127.0.0.1
0 ipaddress: rejected -> guard says "hostname" inet_aton -> 0.0.0.0
The first three are 127.0.0.1 to inet_aton(), 0 is 0.0.0.0 - which
reaches services bound to localhost on Linux - and none of them is an IP
address to the strict parser the guard was written against. These are not
exotic: inet_aton() is specified to accept the three-part, two-part and
one-part dotted forms as well as decimal, octal (leading 0) and hexadecimal
(leading 0x) components, so 2130706433, 127.1 and 0x7f.0x0.0x0.0x1 are
all documented spellings of the same address rather than parser bugs.
Whether a given platform's resolver routes a hostname through that parsing differs, so a guard can behave differently on a developer machine and on the deployment target. That makes it a portability trap as well as a security one, and it is a reason to stop reasoning about the string at all rather than a list of spellings to add to a blocklist.
The conclusion is not "enumerate these formats". It is that the host string is the wrong thing to validate. Resolve it, and check the addresses you are actually going to connect to.
Remediation Steps
Core Principle: Never let untrusted input decide the destination of a server-side outbound request. The set of reachable destinations is defined and enforced by the server, through allowlists, address checks made after resolution, and egress controls.
Locate the SSRF vulnerability
- Identify the source: where URL or destination data enters (user input, external files, databases, API parameters)
- Trace how the URL is built from that data
- Locate the sink: the call that issues the request (
requests.get(),HttpClient,fetch(),curl_exec())
Implement URL allowlists (Primary Defense)
- Keep an explicit list of permitted destinations, such as
ALLOWED_DOMAINS = ['api.example.com', 'cdn.example.com'] - Check the scheme, host, port and path, not the host alone
- Match domains exactly, or with a tightly controlled pattern - no wildcards
- Reject anything not on the list, with an explicit error
- Do not invert the list into a denylist: attackers will find a spelling it does not cover
Block private IP ranges and metadata endpoints (Defense in Depth)
- Block the private ranges
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16- and the ranges a filter written from memory omits:100.64.0.0/10(carrier-grade NAT),192.0.0.0/24,198.18.0.0/15(benchmarking),240.0.0.0/4, the documentation ranges192.0.2.0/24,198.51.100.0/24,203.0.113.0/24and2001:db8::/32and the discard-only100::/64(nothing legitimate lives in any of them, and a filter that admits them has a gap no must-allow test will notice),fc00::/7(where IPv6 private networks are) and the deprecatedfec0::/10 - Block loopback:
127.0.0.0/8,localhost,::1 - Block link-local
169.254.0.0/16, where the AWS and Azure metadata service answers at169.254.169.254 - Block multicast:
224.0.0.0/4andff00::/8. A list built from a private-address checklist usually omits them, and a helper that only covers link-local multicast leaves the rest of the range open - Block the cloud metadata hostnames
metadata.google.internaland its short formmetadata(GCP),instance-data(AWS). Azure has no hostname for its metadata service - it is reached at169.254.169.254only, which the link-local rule already covers - Apply these ranges to the addresses the host resolves to, not to the hostname: an allowlisted name that answers with a private address is still a rejection
Implement additional SSRF protections
- Set a timeout and a response size limit on the HTTP client
- Validate with the same parser the request itself will use, and decode URL encoding the same way in both places
- Reject the alternative spellings of an address: hex (
0x7f.0.0.1), decimal (2130706433), and every IPv6 form that carries an IPv4 address - the mapped form[::ffff:127.0.0.1], the compatible form[::7f00:1], the translated form[::ffff:0:7f00:1](RFC 6145), and the translation prefixes64:ff9b::/96and64:ff9b:1::/48(NAT64),2002::/16(6to4) and2001::/32(Teredo).Test with SSRF Payloadsbelow says which of these are live routes and which are worth refusing only as encodings - Sign webhook requests with an HMAC, so the receiving end can tell a genuine call from a forged one
Monitor and audit SSRF attempts
- Log every outbound HTTP request: destination URL, the user or source IP that triggered it, timestamp
- Alert on requests to blocked destinations: private ranges, metadata endpoints, unlisted domains
- Watch for rapid requests to varying addresses, which is what internal scanning and rebinding attempts look like in a log
- Review the allowlist periodically, adding destinations that are now legitimate and removing ones nothing uses
Test the SSRF protection
- An allowlisted domain still fetches
- Every address in
Test with SSRF Payloadsbelow is rejected, along with thehttp://user@hostform - A domain that changes its resolution between validation and connection does not reach the second address
- Re-scan with the security scanner to confirm the finding is resolved
Disable or Validate Redirects
- Turn redirect following off
- Where redirects are genuinely needed, put each destination through the same allowlist and address checks as the original URL
- Cap the chain at three to five hops
Most HTTP clients follow redirects by default, so this is usually a setting to change rather than code to add. Each language page shows the flag and the per-hop revalidation for that ecosystem's client.
Use Network Segmentation and Least Privilege
Limit impact of successful SSRF:
- Run the application in a restricted network segment
- Filter egress at the firewall, so the host can only open connections it has a reason to open
- Block the schemes the application does not need:
file://,gopher://,dict:// - Give service accounts the minimum IAM permissions they need
- Do not attach an instance role where the workload has no reason to call the cloud provider
Test with SSRF Payloads
Internal network access:
http://localhost/adminhttp://127.0.0.1:8080/http://192.168.1.1/http://10.0.0.1/http://100.64.0.1/,http://198.18.0.1/,http://[fc00::1]/,http://[fec0::1]/- the ranges a hand-written list usually lacks
Cloud metadata:
http://169.254.169.254/latest/meta-data/(AWS)http://metadata.google.internal/computeMetadata/v1/(GCP)http://169.254.169.254/metadata/instance?api-version=2021-02-01(Azure)
Alternative encodings of 127.0.0.1 - all three are the same address to an
inet_aton-style resolver, and none is an IP address to a strict parser. The
IPv6 spellings are a separate list below, because they are valid IP literals
and slip past a filter by a different route:
http://2130706433/(decimal)http://0x7f.0x0.0x0.0x1/(hex)http://127.1/(shorthand - the one most often missed)
Also http://0/, which resolves to 0.0.0.0 and reaches services bound to
localhost on Linux.
IPv6 spellings of the same address - an IPv4 address can be carried inside
an IPv6 one six different ways, and only the first - the mapped form - is one
the platform recognises as carrying an IPv4 address at all. Re-measured 2026-09-16 on
JDK 26, Go 1.25.5, .NET 10.0.12, ipaddr.js 2.5.0 under Node 24.3.0 and CPython
3.13: ::7f00:1 stays an Inet6Address in Java, To4() is nil for it in Go,
.NET keeps it InterNetworkV6, ipaddr.js ranges it plain unicast and
CPython's ipv4_mapped is None. All five recognise the mapped form
::ffff:127.0.0.1 by contrast, though not in the same way - Java hands back an
Inet4Address and Go's To4() returns 127.0.0.1, while .NET keeps it
InterNetworkV6 with IsLoopback true, ipaddr.js ranges it ipv4Mapped and
CPython fills in ipv4_mapped. That contrast is the point: the compatible form
is the one nothing flags. So every language
page unwraps the compatible form by hand - and the other four reach a range list that never looks at them:
http://[::ffff:127.0.0.1]/(mapped - the one every library recognises; Node renders it[::ffff:7f00:1])http://[::7f00:1]/(compatible - most platforms leave this as IPv6)http://[::ffff:0:7f00:1]/(IPv4-translated, RFC 6145 - one zero group on from the mapped form, so the mapped-form unwrap does not catch it; nothing routes it to a host, so refuse the whole::ffff:0:0:0/96prefix)http://[64:ff9b::7f00:1]/(NAT64 well-known prefix, RFC 6052) andhttp://[64:ff9b:1::7f00:1]/(NAT64 local-use prefix, RFC 8215) - the two differ in what a translator does with them. RFC 6052 section 3.1 says a translator "MUST drop" a packet whose address is the well-known prefix plus a non-global IPv4 address, so[64:ff9b::a9fe:a9fe]reaches the metadata service only through a translator that breaks the specification. RFC 8215 says those restrictions "do not apply" to64:ff9b:1::/48, so[64:ff9b:1::a9fe:a9fe]is the spelling a compliant NAT64 can deliver to 169.254.169.254. A network-specific prefix (RFC 6052 section 2.2) is a third route no list can enumerate. Both listed prefixes are cheap to refuse as encodings; do not read the refusal as a claim about routinghttp://[2002:7f00:1::]/(6to4) and a Teredo address in2001::/32- both need a relay, and relays are largely gone, so treat these as cheap to block rather than urgent
And the other half of the test. A filter that rejects every address above
and also rejects https://api.example.com, 8.8.8.8, [::ffff:8.8.8.8] or
[64:ff9b:2::1] - a prefix IANA assigns to nobody - is not passing, it is
refusing everything. Adding ::ffff:0:0/96 to a Go range list does exactly
that, because net.IP stores IPv4 in that form. Keep must-allow addresses in
the same vector list as the must-block ones.
Those are predicate vectors: run them through the address check. The
end-to-end fetch is a separate assertion, made against a controlled,
allowlisted HTTPS origin - nothing answers at 64:ff9b:2::1, so a test that
expects that address to fetch fails for a reason unconnected to the filter.
Common Vulnerable Patterns
- Using user-provided URLs without validation:
fetch(userUrl),urllib.request.urlopen(url) - Webhook implementations that don't validate destinations
- Image/file fetchers that accept arbitrary URLs
- Proxy endpoints that forward requests
- URL shortener services without destination checks
Unvalidated URL Fetch with Metadata Access
// VULNERABLE - pseudo-code
// Fetching URL from user input without validation
url = request.getParameter("imageUrl")
response = httpClient.get(url) // SSRF vulnerability
// Attack: url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
// Result: Attacker retrieves AWS credentials from metadata service
// Webhook without allowlist
webhookUrl = request.getParameter("callback")
httpClient.post(webhookUrl, data) // SSRF vulnerability
// Attack: webhookUrl = "http://internal-admin-panel:8080/deleteAllUsers"
// Result: Attacker accesses internal services
Why this is vulnerable: The request is made by the server, so it carries the server's network position - and internal services routinely trust that position instead of authenticating. The link-local address 169.254.169.254 is the sharpest case because it is reachable from inside a cloud instance and from nowhere else, which is exactly the assumption the metadata service was designed around.
Two details decide how serious a given finding is. AWS IMDSv2 requires a PUT to obtain a token and a header on every subsequent request, so an SSRF limited to GET without header control fails against an instance where it is enforced - IMDSv1 remains available unless the instance disables it, and Azure and GCP use a required header instead. And a validate-then-fetch check is defeated by DNS rebinding; the durable version resolves once, checks the address, and connects to that address.
Secure Patterns
Domain Allowlist with Private IP Blocking
# Validate the URL, then connect to the address that was validated
allowedDomains = ["cdn.example.com", "api.example.com"]
url = request.getParameter("imageUrl")
parsedUrl = parseURL(url)
if parsedUrl.scheme != "https":
throw SecurityException("Scheme not allowed")
if parsedUrl.hostname not in allowedDomains:
throw SecurityException("Domain not allowed")
addresses = resolveAll(parsedUrl.hostname) // every A and AAAA answer, not the first
if addresses is empty:
throw SecurityException("Host does not resolve")
for address in addresses: // one bad answer poisons the whole set
if isPrivateIP(address):
throw SecurityException("Private IP not allowed")
# Connect to a checked address rather than to the name. Passing `url` here would
# hand the hostname back to the client, which resolves it again at connect time -
# a second lookup this code has not seen and cannot constrain.
response = httpClient.get(
address = addresses[0],
hostHeader = parsedUrl.hostname, // the origin still sees the right Host
tlsServerName = parsedUrl.hostname, // and the certificate still verifies
followRedirects = false, // each hop must repeat all of the above
timeout = 5)
Why this works:
- Restricts outbound requests to pre-approved domains, so an attacker-supplied host is refused before any lookup happens
- Checks every address the name resolves to, not just the first: a hostname can answer with one public and one private address, and a client is free to use either
- Connects to an address this code validated, which is what closes DNS rebinding
- Keeps the
Hostheader and the TLS server name set to the original hostname, so connecting by address does not break name-based virtual hosting or certificate verification - the usual reason this pattern gets reverted - Refuses redirects, since a validated URL that answers
302 Location: http://169.254.169.254/otherwise reaches the metadata service without passing any of these checks
The language pages implement the last three points against real HTTP clients, which is where the detail lives: the hook for "connect to this address instead of resolving that name" differs in every ecosystem, and in some of them the obvious spelling silently re-resolves.
Common Pitfalls
- Validating the hostname string and then handing the original URL to the HTTP client. The client resolves the name again when it connects, so the address it reaches is one the check never saw; a short DNS TTL or a second answer in the same response is enough.
- Blocking known-bad hosts instead of allowing known-good ones. A blocklist of
localhost,127.0.0.1and a few private ranges misses the IPv6 loopback and link-local forms, IPv4-mapped IPv6 addresses, and the decimal, octal and hex encodings a URL parser still accepts; it only ever covers what its author remembered to add, while an allowlist fails closed. - Treating "the URL passed validation" as the end of the check, and leaving the client's default redirect following enabled. An allowlisted URL that answers
302with an internal address inLocationis followed without the new target passing any check.
Language-Specific Guidance
For detailed, language-specific examples and framework-specific patterns:
- C# - HttpClient with URL filtering
- Go - net/http with URL validation and allowlists
- Java - HttpClient, RestTemplate, OkHttp with allowlists
- JavaScript/Node.js - axios, fetch, got, http with SSRF prevention
- PHP - cURL, file_get_contents with hostname validation
- Python - requests, urllib, httpx with URL validation
Additional Resources
- AWS IAM Instance Metadata Security
- AWS SSRF Attacks - Real-world exploitation techniques
- Azure Instance Metadata Service - the
Metadata: trueheader requirement, and confirmation that the service has no hostname, only169.254.169.254 - CWE-918: Server-Side Request Forgery (SSRF)
- GCP: Querying instance metadata - the
Metadata-Flavor: Googleheader requirement, and themetadata.google.internalandmetadatanames - inet_aton(3) - the source for the alternative address spellings: three-part, two-part and one-part dotted forms, and decimal, octal and hexadecimal components
- OWASP SSRF Prevention Cheat Sheet
- OWASP Top 10 2025 A01: Broken Access Control
- PortSwigger SSRF
- RFC 6052, section 3.1 - a translator "MUST drop" a packet whose address is the well-known NAT64 prefix around a non-global IPv4 address, which is why
64:ff9b::7f00:1is refused as an encoding rather than a route - RFC 8215 - the local-use NAT64 prefix
64:ff9b:1::/48, to which that restriction does not apply