Server-side request forgery (SSRF)¶
When is it relevant¶
A new or changed code path where the server makes an outbound request and a user controls part of the destination: the URL, host, port, redirect target or DNS name. Examples are webhooks, URL previews, fetching images or files by URL, integrations, and PDF or HTML rendering.
Attack¶
The attacker points the server at destinations only it can reach:
- localhost admin ports;
- the cloud metadata service (169.254.169.254);
- internal APIs and databases.
Bypasses include redirects, DNS rebinding, and alternative IP encodings such as decimal addresses and IPv4-mapped IPv6. If the response comes back to the attacker, secrets are exposed. If not, side effects and port scanning are still possible.
Mitigation¶
- Allowlist destinations when you can.
- Otherwise, resolve the hostname, reject loopback, private, link-local and metadata ranges, and connect to the address you checked so DNS rebinding can't swap it.
- Don't follow redirects, or check each hop the same way.
- Allow only
http:andhttps:, and expected ports. Set timeouts. - Don't return the raw response to the caller.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Allowlist of hosts | You call a known set of third parties | OWASP SSRF |
| Resolve the host, reject private ranges, pin the IP | Users supply arbitrary URLs (webhooks, imports) | Node dns |
| Egress proxy or separate network | Infrastructure can enforce it for every caller | OWASP SSRF |
Vulnerable — fetch whatever the user asks for
router.post('/import', requireAuth, async (req, res) => {
const { data } = await axios.get(req.body.url) // http://169.254.169.254/...
res.json({ imported: data.length })
})
Vulnerable — host checked, then followed anywhere
const url = new URL(req.body.url)
if (!url.hostname.endsWith('example.com')) return res.sendStatus(400)
const { data } = await axios.get(url.href) // redirects are followed by default
evil.example.com passes endsWith, and a 302 to http://127.0.0.1:6379
is followed after the check.
Mitigated — allowlist
const ALLOWED = new Set(['api.partner.com', 'cdn.partner.com'])
const url = new URL(req.body.url)
if (url.protocol !== 'https:' || !ALLOWED.has(url.hostname)) {
return res.status(400).json({ error: 'URL not allowed' })
}
const { data } = await axios.get(url.href, { maxRedirects: 0, timeout: 5000 })
Compare the host with ===/Set.has, never endsWith or includes.
Docs: axios request config
Mitigated — resolve, filter, and pin
import { lookup } from 'node:dns/promises'
import ipaddr from 'ipaddr.js'
async function safeAddress(hostname) {
const { address } = await lookup(hostname) // resolve once
const parsed = ipaddr.parse(address)
if (parsed.range() !== 'unicast') throw new Error('private address')
return address
}
const url = new URL(req.body.url)
if (!['http:', 'https:'].includes(url.protocol)) return res.sendStatus(400)
const address = await safeAddress(url.hostname)
const { data } = await axios.get(url.href, {
maxRedirects: 0,
timeout: 5000,
lookup: (_h, _o, cb) => cb(null, address, 4), // connect to the IP we checked
})
Passing the resolved address back into the request closes the DNS-rebinding gap
where the name resolves to a public IP during the check and to 127.0.0.1 for
the actual connection.
Docs: Node dns.lookup · ipaddr.js · axios request config
Bypasses to check
- Redirects:
maxRedirectsleft at the default, or followed manually without re-checking each hop. - Check then change: the URL is validated, then rebuilt, re-parsed, or
string-concatenated (
${base}${path}) before the request. - Address encodings that still reach the loopback or link-local range:
127.1,0,0x7f.1,2130706433,[::1],[::ffff:127.0.0.1],localtest.meand other public names that resolve to private space. - Cloud metadata:
169.254.169.254,metadata.google.internal, and100.100.100.200. - Non-HTTP schemes:
file://,gopher://,ftp://,dict://— allow onlyhttp:andhttps:. - Credentials in the authority (
https://api.partner.com@evil.com/) fooling a naivestartsWithon the URL string rather than onurl.hostname. - The response returned to the caller (status, body, timing, redirect location) turning a blind SSRF into a readable one.
- Parsers that fetch on your behalf: PDF and image renderers, SVG, XML external entities, webhooks, "preview this link", RSS import.
What to grep for
axios.get(,fetch(,got(,http.request(,request(new URL(,url.hostname,endsWith(,includes(,startsWith(maxRedirects,followRedirect,webhookUrl,callbackUrl,imageUrl