SSRF explained: why every proxy needs a guard against internal IPs
What server-side request forgery is, how attackers reach internal services and cloud metadata through URL fetchers, and the defenses that actually hold up.
Any feature that fetches a URL on a user’s behalf is a door into your network. Link previews, webhook senders, image importers, PDF renderers and every kind of proxy share the same risk: server-side request forgery, or SSRF. It is common and serious enough that OWASP gave it its own entry, A10, in the 2021 Top 10.
This article explains SSRF with concrete examples, shows the tricks attackers use to get past naive filters, and walks through defenses that hold up.
What SSRF is
Server-side request forgery happens when an attacker can make your server send a request to a destination of their choosing. The request comes from your server, so it carries your server’s network position and, sometimes, its credentials.
The classic setup looks innocent:
// "Fetch a preview for any URL the user pastes"
app.get('/preview', async (req, res) => {
const page = await fetch(req.query.url); // ← the attacker chooses this
res.send(extractTitle(await page.text()));
});
From the internet, an attacker cannot reach http://10.0.0.5/admin. Your server can.
What attackers go after
| Target | Example address | Why it matters |
|---|---|---|
| Cloud instance metadata | 169.254.169.254 | Can expose temporary cloud credentials and instance data |
| The server itself | 127.0.0.1, localhost | Admin panels, debug endpoints and databases bound to loopback |
| Private network services | 10.0.0.0/8, 192.168.0.0/16 | Internal APIs that trust anything on the network |
| Other ports | http://internal-host:6379/ | Non-HTTP services such as caches that accept text commands |
Cloud metadata is the headline risk. On AWS, the Instance Metadata Service answers on a link-local address, and with the original IMDSv1 a single GET could return credentials for the instance’s role. IMDSv2 requires a session token obtained with a PUT request first, which blocks most SSRF against it. Use IMDSv2 wherever you can, but do not rely on it alone.
Why simple filters fail
The first instinct is to check the hostname: refuse localhost, refuse anything starting with 10. or 192.168.. Attackers have many ways around string checks.
Alternative IP notations
The same address can be written many ways. 127.0.0.1 can also be 2130706433 (decimal), 0x7f000001 (hex), 127.1 (shortened) or [::ffff:127.0.0.1] (IPv4-mapped IPv6). On Linux, 0.0.0.0 connects to the local machine too.
IPv6 and transition addresses
IPv6 has its own loopback (::1), link-local (fe80::/10) and unique-local (fc00::/7) ranges. Transition formats such as 6to4 (2002::/16) and NAT64 (64:ff9b::/96) embed IPv4 addresses, so a filter that only understands IPv4 can be bypassed with an IPv6 literal that routes back into private IPv4 space.
DNS that points inside
An attacker can register metadata.attacker.example and point it at 169.254.169.254. The hostname looks external, so a hostname check passes, but the connection goes inside.
DNS rebinding
A smarter variant: the attacker’s DNS server answers with a harmless public address when you check, then with an internal address a moment later when you connect. If you resolve the name once to validate it and again to connect, you check one address and connect to another.
Redirects
You validate https://attacker.example/start, it passes, and the server answers with 302 Location: http://169.254.169.254/latest/meta-data/. If your HTTP client follows redirects automatically without re-validating, the check was pointless.
Defenses that hold up
1. Allowlist destinations when you can
If a feature only needs to reach a known set of hosts, such as a list of webhook providers or an image CDN, allow exactly those and refuse everything else. An allowlist is far easier to get right than a blocklist.
2. Validate the address you actually connect to
For features that must reach arbitrary public URLs, check the IP address at connection time, after DNS resolution, not the hostname before it. Most languages let you hook the dialer. In Go:
dialer := &net.Dialer{
Control: func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip, err := netip.ParseAddr(host)
if err != nil {
return err
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
return fmt.Errorf("blocked address %s", ip)
}
return nil
},
}
client := &http.Client{Transport: &http.Transport{DialContext: dialer.DialContext}}
Because the check runs on the resolved address immediately before the connection, DNS rebinding and alternative notations both fail. Extend the check beyond the standard library’s helpers to cover carrier-grade NAT (100.64.0.0/10), multicast, documentation ranges and the IPv6 transition prefixes.
3. Re-check every redirect
Either disable automatic redirects and follow them yourself, validating each hop, or make sure each redirect goes through the same guarded dialer. Also cap the number of redirects.
4. Restrict schemes and ports
Allow http and https only. Refuse file:, gopher:, ftp: and anything else your client library might support. If you can, restrict ports to 80 and 443.
5. Limit what the fetcher can reach anyway
Defense in depth matters because the checks above are code, and code has bugs:
- Run URL-fetching work in an environment with egress rules that block internal networks.
- Use IMDSv2 or the equivalent on other clouds, and give instance roles the least privilege possible.
- Don’t return raw upstream responses or error messages to users when they are not needed; they help attackers map your network.
SSRF in webhooks and integrations
Proxies and link previews are the obvious cases, but SSRF hides in features that do not look like fetchers at all:
- Webhooks. A customer enters a callback URL, and your server sends events to it. If that URL can be
http://10.0.0.5/, the customer can probe your network, and sometimes read responses through delivery logs or error messages. - Imports. “Import from URL” for images, spreadsheets, calendars or feeds.
- Document rendering. HTML-to-PDF and screenshot services that load whatever a page references, including images and iframes pointing inside.
- OAuth and OpenID discovery. Features that fetch metadata from a URL supplied in configuration.
Apply the same guard to every outbound request whose destination a user can influence, not just the feature you think of as a proxy.
Testing your own code for SSRF
In a test environment you control, try destinations like these against every URL-accepting feature, and confirm each one is refused:
http://127.0.0.1/ http://[::1]/
http://2130706433/ http://0.0.0.0:8080/
http://169.254.169.254/ http://[::ffff:169.254.169.254]/
http://localtest.me/ (a public name that resolves to 127.0.0.1)
https://your-server.example/redirect?to=http://127.0.0.1/
Also test a hostname you control that you can switch between a public and a private address, to confirm the check happens at connection time rather than once up front.
Why every proxy needs this
A CORS proxy or API gateway exists to fetch URLs that someone else chooses. Without an SSRF guard, a public proxy is an open door into the network it runs in. That is one of the reasons we advise against running an unguarded open proxy, as covered in API gateway vs reverse proxy vs CORS proxy.
ProxifyEdge applies these defenses to every proxied request (limits):
- Destinations are checked at connection time, after DNS resolution, so a hostname that changes its answer between the check and the dial is still caught. Every address the resolver returns is checked.
- Private, loopback, link-local (including the metadata address), carrier-grade NAT, multicast and documentation ranges are refused, along with their IPv6 equivalents and the transition prefixes that embed IPv4 addresses, such as 6to4, Teredo and NAT64.
- Redirects are followed up to five times, and each hop is checked against the same rules.
- Only absolute
httpandhttpstargets are accepted.
Key takeaways
- SSRF lets an attacker make your server request addresses they cannot reach themselves, including cloud metadata and internal services.
- Hostname and string checks fail against alternative notations, IPv6 forms, internal DNS records, rebinding and redirects.
- Allowlist destinations when possible; otherwise validate the resolved IP address at connection time.
- Re-validate every redirect hop, allow only
httpandhttps, and restrict ports. - Back the code with network egress rules and least-privilege cloud credentials, and use IMDSv2.