Server-Side Request Forgery
Your server fetches a URL that a user handed it. If nothing checks the host, the user can point it at addresses only your server can reach: localhost, the private network, and the cloud metadata service that hands out IAM credentials. The scanner probes every URL-shaped parameter with those addresses, and proves the blind cases with an out-of-band callback.
The attack shape: your server as a proxy
Three parties. The attacker is on the internet. The internal targets only accept connections from inside the network. Your server is on both sides, and it will fetch whatever URL it is given.
1The attacker sits on the internet, the internal network only routes from inside, and your server is on both. There is no direct route from the attacker to 169.254.169.254, Redis or the admin panel.
2The attacker sends your server a URL in a parameter it fetches: url=http://169.254.169.254/latest/meta-data/iam/security-credentials/. Nothing checks the host.
3Your server fetches it. To the metadata service the request comes from a trusted host inside the VPC. The server has become the attacker's proxy.
4IMDSv1 answers with the instance's IAM role credentials and the server relays the body back. The Access Key and Secret Key now work from anywhere.
5The same move reaches anything the server can: Redis or Memcached with no auth, an admin panel bound to localhost, and by walking ports, a map of the private network. Some implementations also honour file:///etc/passwd.
How the scanner probes
The scanner does the attacker's move with harmless addresses and reads the reply for proof. It only touches parameters whose name says they carry a URL, and it carries the authenticated session so server-rendered routes answer at all.
1The scanner starts from the endpoint inventory and the authenticated session the crawl produced: a bearer token, a session cookie, or both.
2Only URL-shaped parameters are tested. A name qualifies if it contains a token such as url, uri, link, href, src, image, avatar, callback, webhook, redirect, next or proxy, so imageUrl and callbackUri match by substring while q is skipped. Query parameters and POST body fields both count.
3A benign baseline URL (http://example.invalid/) is sent first to record the normal response. Then six probe URLs go into each such parameter: localhost in four spellings, the AWS metadata path and the GCP metadata host. The request carries the session cookie, because server-rendered routes often authenticate by cookie rather than a bearer header.
4Flag only if the response body contains a success indicator: ami-id, instance-id or iam/security-credentials for AWS, computeMetadata or project/project-id for GCP. The marker must be absent from a baseline response fetched with a benign URL first, so an ordinary HTML page or a docs page that merely mentions a marker is not flagged.
5A hit is reported as SSRF on that parameter, with the probe URL as evidence. A plain 200 proves nothing either way: the server may have fetched the URL and discarded the body. That is blind SSRF, and it is handled out of band.
Blind SSRF: proving a fetch the response never shows
Many sinks fetch the URL and show nothing: an avatar is stored, a webhook is "registered", a 200 comes back. The scanner cannot see the fetch from the response, so it makes the fetch visible somewhere else. Two pipeline phases bracket the dynamic scanners for this.
1Phase 3.5. Before the dynamic scanners run, the scanner registers a session with the out-of-band callback server. Every callback URL it mints is unique: the session id plus a per-payload nonce form the subdomain, so a later hit can be traced to one endpoint and one parameter.
2The sink is usually a form field. Here the crawl found POST /profile/image/url with an imageUrl input. When the browser crawl cannot reach an auth-gated page, the scanner fetches the common authenticated pages (/dashboard, /profile, /settings, /account, …) over HTTP with the session and reads their <form> targets directly.
3The callback URL is POSTed into the URL-shaped body field, form-encoded, with the session cookie. Only URL-shaped fields get it, and the number of POST registrations is capped (MAX_OOB_POST_ENDPOINTS) so the external service is not flooded.
4The target fetches the URL. Its reply to the scanner is a plain 200 with nothing of the fetch in it. The fetch itself lands on the OOB server, which records the DNS lookup and the HTTP request together with the source address.
5Phase 5.6. After the dynamic scanners finish, the scanner polls the OOB server, decrypts the recorded interactions, and matches each nonce back to its endpoint and parameter.
6A single callback proves the server made the request. The finding is reported as blind SSRF on imageUrl, even though no response body ever showed it.
An SPA talks to its API with a bearer header, so a scanner that only forwards the bearer gets a 401 from a server-rendered form route and never reaches the sink. The OOB step sends the crawl's full session, cookie included, and is not gated on a bearer token being present.
Why it matters
SSRF lets an attacker use your server as a proxy to reach what the internet cannot:
- Steal cloud credentials. The AWS metadata endpoint returns the IAM role's Access Key and Secret Key.
- Reach internal services. Databases, admin panels, and caches like Redis and Memcached that were never exposed.
- Port-scan the private network. Map your infrastructure from outside.
- Read local files.
file:///etc/passwdon some implementations.
Real-world breaches
An SSRF vulnerability in a WAF configuration let an attacker reach the AWS metadata endpoint (IMDSv1), steal IAM credentials, and exfiltrate 100+ million customer records from S3. The attacker was convicted. Capital One was fined $80 million.
A bug bounty researcher found SSRF in Shopify's infrastructure that allowed access to internal cloud metadata endpoints. Patched via their bug bounty program.
The fix: two gates before the fetch
An allowlist of hostnames stops arbitrary targets. Resolving the name and refusing private addresses stops an allowed name that points inside. Both gates run before fetch.
1The happy path. The hostname is on the allowlist, DNS gives a public address, and the fetch runs. Everything the attacker controls has been checked before any connection is opened.
2Gate one: the allowlist. A raw metadata IP, localhost, or any other host not on the list is refused with 403. Compare hostnames, never substrings of the URL.
3Gate two: resolve, then refuse private ranges. An allowed name can still point at an internal address, so the code resolves it and rejects anything in a private or link-local range before fetching.
Vulnerable vs fixed
// BAD: Fetches any URL provided by the user
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get("url")
const response = await fetch(url!) // No validation
const data = await response.text()
return new Response(data)
}// GOOD: Validate URL against allowlist
const ALLOWED_DOMAINS = ["images.example.com", "cdn.example.com"]
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get("url")
const parsed = new URL(url!)
if (!ALLOWED_DOMAINS.includes(parsed.hostname)) {
return new Response("Forbidden", { status: 403 })
}
// Also block internal IPs
const ip = await dns.resolve(parsed.hostname)
if (isPrivateIP(ip)) {
return new Response("Forbidden", { status: 403 })
}
const response = await fetch(url!)
return new Response(await response.arrayBuffer())
}Limits and what it deliberately does not do
| Aspect | Behaviour |
|---|---|
| Which parameters | Only names containing a URL-shaped token (url, uri, link, href, src, source, redirect, next, return, callback, image, img, photo, avatar, webhook, endpoint, target, dest, fetch, load, proxy). A URL sink behind a name like q is not probed. |
| Probes sent | Six: 127.0.0.1, localhost, 0.0.0.0, [::1], the AWS metadata path, the GCP metadata host. No file:// probe is sent. |
| In-band proof | A response body containing a metadata indicator or a full HTML page. A 200 with a neutral body is not a finding. |
| Blind proof | One DNS or HTTP interaction on the callback server, matched by nonce to the endpoint and parameter. Requires the OOB registration in phase 3.5 to have succeeded; if no callback server answers, the blind path is skipped and the scan continues. |
| POST-body registrations | Capped by MAX_OOB_POST_ENDPOINTS, one of the two caps kept because it bounds an external service. |
| Safety | Probes are reads of harmless addresses. Nothing is written to the target or to the internal services it may reach. |