Skip to content

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:

  1. Create a domain that initially resolves to a legitimate IP
  2. Application validates the IP (passes allowlist)
  3. Attacker changes DNS to point to internal IP (127.0.0.1, 192.168.x.x)
  4. Application makes request using cached DNS or re-resolves
  5. Request goes to internal service

Defending against it takes three steps, in this order:

  1. Validate the hostname against the allowlist before resolving anything.
  2. 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.
  3. 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] and 127.0.0.1 are the same address, and a check comparing text sees two different hosts. Java returns a null host for some forms, so code reading getHost() gets null and 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 ranges 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 and 2001:db8::/32 and the discard-only 100::/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 deprecated fec0::/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 at 169.254.169.254
  • Block multicast: 224.0.0.0/4 and ff00::/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.internal and its short form metadata (GCP), instance-data (AWS). Azure has no hostname for its metadata service - it is reached at 169.254.169.254 only, 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 prefixes 64:ff9b::/96 and 64:ff9b:1::/48 (NAT64), 2002::/16 (6to4) and 2001::/32 (Teredo). Test with SSRF Payloads below 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 Payloads below is rejected, along with the http://user@host form
  • 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/admin
  • http://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/96 prefix)
  • http://[64:ff9b::7f00:1]/ (NAT64 well-known prefix, RFC 6052) and http://[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" to 64: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 routing
  • http://[2002:7f00:1::]/ (6to4) and a Teredo address in 2001::/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 Host header 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.1 and 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 302 with an internal address in Location is 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