Server-Side Request Forgery (SSRF)
SSRF is a vulnerability where an attacker makes a server send requests to destinations they choose, reaching internal services and cloud credentials.
Also known as Server Side Request Forgery, CWE-918
Server-side request forgery (SSRF) is a vulnerability where an application fetches a URL an attacker controls, so the request leaves from the server's network position instead of the attacker's. That lets attackers reach localhost admin ports, internal APIs, and cloud metadata endpoints that hand out credentials. SSRF is catalogued as CWE-918.
The most valuable SSRF target is the cloud instance metadata service at 169.254.169.254, which returns temporary IAM credentials to anything that asks from inside the instance. The common defense, checking the URL string against a blocklist, fails because URL parsers, DNS, and redirects can all change the destination after the check runs. Our position: validate the IP address the server actually connects to, repeat that on every redirect hop, and enforce egress rules at the network layer so a missed check fails closed.
What is SSRF?
#SSRF lends a server's network privileges to whoever supplies a URL. Perimeter security assumes that traffic from outside is untrusted and traffic from inside is trusted. A web server in a private network can usually reach things the internet cannot: Redis on 127.0.0.1:6379 with no password, an admin API on 10.0.3.17, a Kubernetes API server, and the metadata service. Those services often skip authentication because network position is the authentication.
Any feature that fetches a URL on a user's behalf breaks that assumption. Here is the minimal vulnerable endpoint:
app.get('/preview', async (req, res) => {
const r = await fetch(req.query.url) // the caller picks the destination
res.type('text/plain').send(await r.text())
})A request for /preview?url=http://127.0.0.1:6379/ turns the server into a proxy into its own network. The attacker never opens a connection to the internal service. The server does, from its own IP, over its own routes.
Real SSRF rarely looks this obvious. It hides in webhook URLs, link previews, "import from URL" features, PDF and image renderers that load remote assets, OAuth and OpenID Connect discovery documents, and XML parsers that resolve external entities (XXE). In blind SSRF the response never reaches the attacker, but timing, DNS lookups, and side effects on internal systems still leak information.
OWASP added SSRF to the 2021 OWASP Top 10 as A10 because practitioners ranked it first in the community survey, even though the data showed a 2.72% incidence rate across 385 mapped CVEs (OWASP A10:2021). The 2025 edition folded it into A01 Broken Access Control, where CWE-918 is listed as a notable Common Weakness Enumeration entry. That reclassification is accurate: SSRF is an access control failure where the network was the only access control.
The diagram shows the three hops of an SSRF attack. The attacker on the public internet can reach only the app server, because the perimeter firewall allows inbound traffic on port 443 and nothing else. A direct connection from the attacker to any internal address is blocked. The attacker sends GET /preview?url=http://169.254.169.254/... to the app server, which runs fetch(req.query.url). Every request the app server makes leaves from its own IP inside the private network, so it can reach Redis on 127.0.0.1:6379 (no password), an internal admin API on 10.0.3.17:8080 that trusts the network, and the cloud metadata service on 169.254.169.254, which returns temporary IAM role credentials.
How does SSRF steal cloud credentials?
#SSRF steals cloud credentials by asking the instance metadata service for the IAM role attached to the server. On AWS, the original IMDSv1 is an unauthenticated HTTP API on a link-local address. One GET request returns working keys. This is the request and response shape from the AWS documentation:
$ curl http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name
{
"Code" : "Success",
"LastUpdated" : "2012-04-26T16:39:16Z",
"Type" : "AWS-HMAC",
"AccessKeyId" : "ASIAIOSFODNN7EXAMPLE",
"SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Token" : "token",
"Expiration" : "2017-05-17T15:09:54Z"
}Those credentials work from any machine until they expire. An SSRF in a link preview feature becomes a cloud account compromise scoped only by what the instance role is allowed to do.
The canonical case is the 2019 Capital One breach. Capital One says an outside individual accessed its data on March 22 and 23, 2019, affecting about 100 million people in the United States and 6 million in Canada, including about 140,000 US Social Security numbers (Capital One). The Justice Department said the intrusion "occurred through a misconfigured web application firewall" (DOJ). Contemporary reporting reconstructed the path: the firewall could be tricked into relaying requests to the metadata service, which returned the role's credentials (KrebsOnSecurity).
AWS shipped IMDSv2 in November 2019. A caller must first send a PUT to obtain a session token, then present that token on every read:
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/role-nameEach IMDSv2 design choice targets a specific failure mode, per AWS's design write-up. Many SSRF primitives and open proxies can relay only a GET, so the token requires a PUT. Reverse proxies and WAFs commonly add an X-Forwarded-For header, so IMDSv2 refuses to issue tokens to requests that carry one. The token response leaves with an IP TTL of 1, so a misconfigured NAT or layer 3 firewall cannot carry it off the host.
As of November 2026, IMDSv2-only is the default for Amazon Linux 2023, for console Quick Start launches since November 2023, and for EC2 instance types released since mid-2024 (AWS). Older AMIs and launch templates still commonly allow IMDSv1, so check that HttpTokens is required on every instance instead of assuming.
IMDSv2 has three limits that matter:
- An SSRF primitive that lets the attacker choose the method and headers, such as a webhook feature with custom headers, can complete the
PUTitself. - IMDSv2 protects only the metadata endpoint. Redis, admin APIs, and every other internal service get nothing from it.
- IMDSv2 does nothing against code already running on the instance.
The third limit is where SSRF meets the software supply chain. Our analysis of the TanStack npm compromise in May 2026, part of the Mini Shai-Hulud campaign, found the worm acquired an IMDSv2 session token with the correct PUT and header before reading role credentials. That flow succeeds against hardened instances that have disabled IMDSv1. An infostealer inside a dependency runs as your process and needs no forged request. IMDSv2 is an SSRF control, not a malicious code control.
Why do SSRF blocklists fail?
#SSRF blocklists fail because the code that checks a URL and the code that fetches it disagree about where it points. Orange Tsai's Black Hat USA 2017 research showed parser differentials across cURL, PHP, Python, Ruby, Node.js, and Go, and summarized the whole class in one line: "It's all about the inconsistency between URL parser and requester."
IP literals are the simplest case. IPv4 carries decades of legacy notation that the WHATWG URL parser and getaddrinfo still accept: hex, octal, a single 32-bit integer, and shortened dotted forms. A filter that string-matches 127.0.0.1 or 169.254.169.254 misses all of them.
We tested this against the ip npm package (node-ip), whose isPublic() function was a common SSRF guard. The script below passes each input through the WHATWG URL parser, through getaddrinfo via dns.lookup, and through isPublic() in versions 2.0.0 and 2.0.1, on Node.js 22.23.4:
const dns = require('node:dns').promises
const ip200 = require('ip200') // npm i ip200@npm:ip@2.0.0
const ip201 = require('ip201') // npm i ip201@npm:ip@2.0.1
const inputs = ['127.0.0.1', '0x7f.1', '127.1', '2130706433', '0177.0.0.1',
'01200034567', '012.1.2.3', '::ffff:127.0.0.1', '::fFFf:127.0.0.1',
'000:0:0000::01', '169.254.169.254', '0xa9fea9fe', '2852039166']
;(async () => {
for (const s of inputs) {
const host = new URL('http://' + (s.includes(':') ? `[${s}]` : s) + '/').hostname
const addr = (await dns.lookup(s, { all: true })).map(a => a.address).join(',')
console.log([s, host, addr, ip200.isPublic(s), ip201.isPublic(s)].join(' | '))
}
})()| Input | Resolves to | ip@2.0.0 isPublic() | ip@2.0.1 isPublic() |
|---|---|---|---|
| 127.0.0.1 | 127.0.0.1 | false | false |
| 0x7f.1 | 127.0.0.1 | true | false |
| 127.1 | 127.0.0.1 | true | true |
| 2130706433 | 127.0.0.1 | true | false |
| 0177.0.0.1 | 127.0.0.1 | true | false |
| 01200034567 | 10.0.57.119 | true | true |
| 012.1.2.3 | 10.1.2.3 | true | true |
| ::ffff:127.0.0.1 | ::ffff:7f00:1 (127.0.0.1) | false | false |
| ::fFFf:127.0.0.1 | ::ffff:7f00:1 (127.0.0.1) | false | true |
| 000:0:0000::01 | ::1 | true | true |
| 169.254.169.254 | 169.254.169.254 | false | false |
| 0xa9fea9fe | 169.254.169.254 | true | false |
| 2852039166 | 169.254.169.254 | true | true |
Every input points at loopback, a private network, or the metadata service, so isPublic() should return false for all 13. Version 2.0.0 returned true for 9 of the 13. In a separate check, it also returned true for the hostname localhost. That was CVE-2023-42282, fixed in 2.0.1 in February 2024. The fix was incomplete: CVE-2024-29415 lists 127.1, 01200034567, 012.1.2.3, 000:0:0000::01, and ::fFFf:127.0.0.1 as still misclassified, and as of November 2026 the advisory lists no patched version. Our run reproduces all five. It also shows 2.0.1 regressing on the mixed-case IPv4-mapped address that 2.0.0 handled, and still passing 2852039166, the metadata service address written as an integer. Version 2.0.1 throws on hostnames instead of answering.
The first CVE was filed as critical (9.8) against a package with over 16 million weekly downloads, then lowered to low after the maintainer disputed it, a dispute heated enough that he briefly archived the repository in mid-2024 (the node-ip CVE dispute). Both sides had a point. The encoding bugs were real. But isPublic() was never a sound SSRF control, because a string classifier cannot classify a hostname. A domain like rebind.attacker.example passes any check that looks at the string, then resolves to 127.0.0.1.
DNS makes it worse. An attacker who controls a domain can serve a record with a TTL of zero that returns a public address on the first lookup and 127.0.0.1 on the second. If the application resolves the name to validate it and then lets the HTTP client resolve it again to connect, the check and the connection see different addresses. The OWASP SSRF Prevention Cheat Sheet puts it plainly: "A second, unchecked DNS lookup between validation and connection can bypass these checks." Redirects are the third bypass. A validated public URL answers 302 Location: http://169.254.169.254/..., and a client that follows redirects walks straight in.
The diagram compares two request flows for http://rebind.attacker.example/. In the vulnerable flow, the application validates with dns.lookup(host), gets 203.0.113.10 (public, TTL 0), and the check passes. Because the TTL was 0, the cached answer expires. When fetch(url) runs, the client resolves the name again and the attacker's DNS server now answers 127.0.0.1, so the TCP connection goes to 127.0.0.1:80 and the check is bypassed. In the pinned flow, a single lookup happens inside the HTTP client's connect step, every returned address is checked, and the client connects to the exact address that was checked. The same two steps repeat on every redirect hop.
SSRF in open source dependencies
#SSRF in open source dependencies usually lives in HTTP clients and frameworks that build URLs for you. Three npm advisories show the pattern:
- CVE-2024-39338: axios 1.3.2 through 1.7.3 processed path-relative URLs as protocol-relative, so a server-side request to a path could reach another host. Fixed in 1.7.4.
- CVE-2025-27152: an absolute URL passed to an axios client created with
baseURLsilently ignores the base, sending the client's default headers, often API keys, to whatever host the URL names. Fixed in 1.8.2 and 0.30.0. - CVE-2024-34351: self-hosted Next.js 13.4.0 up to 14.1.1, with a Server Action that redirects to a relative path, could be made, via a modified
Hostheader, to send requests that appear to come from the application server. Fixed in 14.1.1.
The axios baseURL case fits in two lines, from the advisory's proof of concept:
const client = axios.create({ baseURL: 'http://localhost:10001/' })
const response = await client.get('http://localhost:10002/') // reaches 10002Each of these advisories reaches your scanner as a CVE alert, and each has the same precondition: attacker-controlled input must reach the URL argument. A severity score cannot see that. Reachability analysis can rule out code paths you never call, and Socket's Tier 1 reachability typically filters about 80% of vulnerabilities as irrelevant. SSRF exposes a specific limit worth naming. Function-level reachability will confirm that your code calls axios.get, which for an HTTP client is almost always true and tells you little. Whether the URL comes from a request parameter is a data-flow question about your own code. Treat a reachable SSRF CVE as an instruction to audit the call sites, not as proof of exploitability.
The inverse risk is a dependency that makes requests nobody asked it to make. Malicious packages reach the metadata service without any SSRF bug, and they rarely receive a CVE. Socket's Network access alert flags packages that import network modules or call their functions, which is the signal to watch when a utility library that never touched the network starts doing so in a patch release.
How do you prevent SSRF?
#You prevent SSRF by validating the IP address the server actually connects to, not the URL string the user sent, and by denying egress to internal ranges at the network layer. The code below implements the first half for Node.js with undici. It resolves each hostname once inside the client's connect step, rejects the connection if any resolved address falls in a denied range, connects to the address it checked, and repeats the check on every redirect hop:
const net = require('node:net')
const dns = require('node:dns')
const { Agent, fetch } = require('undici')
const deny = new net.BlockList()
for (const [a, p] of [['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.168.0.0', 16]]) deny.addSubnet(a, p, 'ipv4')
for (const [a, p] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10]]) deny.addSubnet(a, p, 'ipv6')
function isDenied (address) {
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address) // ::ffff:127.0.0.1
if (mapped) address = mapped[1]
if (/^::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/i.test(address)) { // ::ffff:7f00:1
const [hi, lo] = address.split(':').slice(-2).map(h => parseInt(h, 16))
address = [hi >> 8, hi & 255, lo >> 8, lo & 255].join('.')
}
return deny.check(address, net.isIPv6(address) ? 'ipv6' : 'ipv4')
}
// Runs on every DNS-named connection, so check and connect use one answer.
function lookup (hostname, opts, cb) {
dns.lookup(hostname, { ...opts, all: true }, (err, addrs) => {
if (err) return cb(err)
const bad = addrs.find(a => isDenied(a.address))
if (bad) return cb(Object.assign(new Error(`${hostname} resolves to ${bad.address}`), { code: 'ESSRF' }))
opts.all ? cb(null, addrs) : cb(null, addrs[0].address, addrs[0].family)
})
}
const dispatcher = new Agent({ connect: { lookup } })
async function safeFetch (input, init = {}, hops = 5) {
const url = new URL(input)
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`scheme ${url.protocol} not allowed`)
const host = url.hostname.replace(/^\[|\]$/g, '')
// IP literals never reach lookup(), so check them here.
if (net.isIP(host) && isDenied(host)) throw new Error(`literal ${host} denied`)
const res = await fetch(url, { ...init, dispatcher, redirect: 'manual' })
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
if (hops === 0) throw new Error('too many redirects')
return safeFetch(new URL(res.headers.get('location'), url), init, hops - 1)
}
return res
}
module.exports = { safeFetch, isDenied }
if (require.main === module) (async () => {
for (const u of ['http://0x7f.1:8080/', 'http://2852039166/latest/meta-data/', 'http://[::ffff:7f00:1]/',
'http://localhost:3000/admin', 'file:///etc/passwd']) {
try { await safeFetch(u); console.log(u, '-> allowed') } catch (e) { console.log(u, '->', e.cause?.message ?? e.message) }
}
})()Output of node safe-fetch.js (undici 8.12.4, Node.js 22.23.4):
http://0x7f.1:8080/ -> literal 127.0.0.1 denied
http://2852039166/latest/meta-data/ -> literal 169.254.169.254 denied
http://[::ffff:7f00:1]/ -> literal ::ffff:7f00:1 denied
http://localhost:3000/admin -> localhost resolves to 127.0.0.1
file:///etc/passwd -> scheme file: not allowedOne detail surfaced only when we ran it. Node's custom lookup hook never fires for IP literals. Without the explicit literal check, the URL parser normalizes http://0x7f.1:8080/ to 127.0.0.1, and undici connects to it directly, skipping the guard. In our first version the request went out and failed with ECONNREFUSED 127.0.0.1:8080 instead of being blocked. A guard that lives only in DNS resolution has a hole exactly where attackers start.
The guard covers one HTTP client. Any other client in the same process, an image library, a PDF renderer, an SDK with its own agent, bypasses it. That is why the network layer has to back it up.
| Control | What it stops | What it misses | Cost |
|---|---|---|---|
| String blocklist on the URL | Literal 127.0.0.1 and 169.254.169.254 | Alternate IP encodings, DNS names, DNS rebinding, redirects | Cheap, and it creates false confidence |
| Hostname allowlist | Requests to any destination not on the list | Rebinding or takeover of an allowed name, open redirects on allowed hosts | Works only when destinations are known in advance |
| Resolve, validate, pin (code above) | Encodings, DNS names, rebinding, and redirects when re-checked per hop | Gaps in the deny list, other HTTP clients in the process, internal services on public IPs | Per-client code that must cover every outbound path |
| IMDSv2 required, hop limit 1 | GET-only SSRF against the metadata service | SSRF with method and header control, every other internal service, code running on the host | One instance setting; very old SDKs break |
| Deny-by-default network egress | Any request to a blocked range, whatever the application bug | Destinations the policy allows | Infrastructure work; arbitrary-URL fetchers need an egress proxy |
| Isolated fetcher with no credentials | Credential theft even when an SSRF succeeds | Whatever that fetcher can still reach | A separate service and network path |
Our verdict: if your service fetches from a fixed set of partners, use a hostname allowlist plus deny-by-default egress and stop there. If it must fetch arbitrary user-supplied URLs, as webhooks, link previews, and crawlers do, run those fetches in an isolated service with no cloud role and deny-by-default egress, and use resolve-validate-pin inside it. Require IMDSv2 everywhere regardless, and scope instance roles by least privilege so a stolen credential buys as little as possible. As our guide to hardening GitHub Actions puts it for CI runners, "Egress control is the last line that still works when an earlier control has already failed." Never rely on a string blocklist alone.
AI agents turn SSRF into a design problem
#An AI agent with a fetch tool is SSRF by design. The model chooses the URL, and prompt injection lets an attacker choose it for the model. BlueRock Security's analysis of more than 7,000 MCP servers found 36.7% vulnerable to SSRF, a flaw demonstrated to retrieve AWS keys from the metadata endpoint. Allowlists do not fit a tool whose job is reading the open web, and per-request validation buried inside an agent framework is easy to miss in review.
The defenses that hold for agents are the network ones: a fetch tool on a host with no instance role, no route to internal ranges, and an egress proxy that enforces the deny list outside the agent's process. An agent that runs its tools in the same process, on the same host, with the same credentials as everything else recreates the exact trust assumption SSRF has exploited since before the cloud: that anything able to send a request from inside the network deserves what the network gives it.
