SSRF and the proxy layer
How a reverse proxy becomes an SSRF weapon, the bypasses an allowlist must survive, and why a locked-down egress proxy is the strongest architectural defence.
Key points
- A proxy sits on both sides of SSRF, since a
proxy_passto a user-controlled host is the vulnerability while a locked-down egress proxy is the strongest fix. - Validating a hostname is always wrong. Validate the resolved address, at connect time, against a deny list of CIDRs.
- DNS rebinding defeats every resolve-then-validate-then-connect design, because the second resolution is a different answer; pin the address you validated.
- An egress proxy moves resolution and policy out of the vulnerable process, so application parsing bugs stop mattering, provided the app cannot bypass it.
Server-side request forgery is an application making an outbound request to an address the attacker chose. The proxy layer appears on both sides of the problem. A reverse proxy that resolves a user-supplied value into an upstream target is not merely vulnerable to SSRF, it is the SSRF primitive, with the added advantage for an attacker that it already sits inside the trust boundary. Conversely, a forward proxy that every outbound request must traverse is the strongest architectural defence available, because it moves name resolution and destination policy out of the process that has the parsing bug.
The distinction that decides which controls work: hostnames are not addresses. Every failed SSRF defence in practice is a check performed on a string that is later re-resolved into something different, or on an address that is later redirected away from.
Angle one: your proxy as the weapon#
Four shapes account for most of it.
| Shape | Where the attacker input lands | Typical give-away |
|---|---|---|
| Reverse proxy with a variable upstream | proxy_pass http://$something; | A resolver directive is present in a server block that has no other reason for one |
| Open forward proxy inside the network | Absolute-form request target or CONNECT | Access log lines whose Host is not one of your domains |
| Image resizer or thumbnailer | A url= parameter fetched server side | Outbound requests from the media tier to arbitrary hosts |
| Webhook or callback registration | A URL stored and fetched later | The fetch happens from a background worker, often on a more privileged subnet |
The last two are worse than they look because the fetch is frequently asynchronous. The request leaves from a worker with different network policy from the web tier, and it leaves minutes after validation ran, which is an unbounded window for the DNS answer to change.
The nginx failure, and the fix#
# Vulnerable. Do not deploy.
server {
listen 80;
resolver 10.0.0.2 valid=10s;
location /fetch/ {
proxy_pass http://$arg_host$request_uri;
}
}Three separate things make this exploitable, and it is worth naming them individually because removing only one leaves it exploitable.
- The variable is the whole authority.
?host=169.254.169.254reaches the metadata service;?host=10.0.4.17:6379reaches an internal service on an arbitrary port. - The
resolverdirective gives nginx its own DNS client. Without a variable, nginx resolves upstreams once at startup using the system resolver. With a variable, resolution happens per request throughresolver, on whatever answer the attacker's DNS server returns, with the TTL the attacker chooses. - The URI is passed through unmodified. nginx documents that when
proxy_passis specified with variables, the request URI is passed as-is rather than being normalised against alocationprefix. Path traversal and encoded separators survive to the upstream. This behaviour and its trailing-slash interactions are covered in nginx proxy_pass and the trailing slash, and can be checked against the proxy_pass URI simulator.
The fix is to make the set of reachable upstreams finite and declared, so that no attacker-supplied string ever reaches DNS:
# Fixed. The variable can only ever hold a value from the map.
map $arg_host $safe_upstream {
default "";
"images.example.com" "images.example.com";
"cdn.example.net" "cdn.example.net";
}
server {
listen 80;
resolver 10.0.0.2 valid=300s;
location /fetch/ {
if ($safe_upstream = "") { return 403; }
proxy_pass https://$safe_upstream/media$request_uri;
proxy_ssl_verify on;
proxy_ssl_server_name on;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_intercept_errors on;
}
}map gives an exact-match table with a default of empty string, and the guard rejects anything that did not match. if inside a location is only reliable with return or rewrite ... last, which is exactly what is used here. The strictest form removes the variable entirely and declares one location per permitted upstream with a static proxy_pass; use that whenever the set is small enough.
Note the three proxy_ssl_* lines. nginx defaults proxy_ssl_verify to off and proxy_ssl_server_name to off, so an upstream HTTPS connection is unauthenticated and sends no SNI unless you say otherwise. An allowlist that ends in an unverified TLS connection is an allowlist that can be answered by anyone who can influence DNS or routing.
The bypasses an allowlist must survive#
| Bypass class | Why naive validation fails | What actually stops it |
|---|---|---|
| DNS rebinding and validate/connect TOCTOU | Validation resolves the name and gets a public address; the HTTP client resolves again at connect and gets 127.0.0.1. Both answers are legitimate for that name | Resolve once, check every returned record, then connect to the pinned address. Enforce at the socket layer, not before it |
| Redirect to an internal address | The allowlist saw the first URL. The Location header is a new destination that was never checked | Disable redirect following, or re-run the full validation on every hop including the final one |
| Decimal, octal and hex IP literals | 2130706433, 0177.0.0.1, 0x7f000001 are all 127.0.0.1 to inet_aton(), but do not match a regex for 127.* | Never regex the string. Parse to a binary address with the platform's own parser, then compare against CIDRs |
| Short-form addresses | 127.1 and 10.1 are valid and expand to 127.0.0.1 and 10.0.0.1 | Same: parse, then compare numerically |
0.0.0.0 | It is not in 127.0.0.0/8, so a loopback-only deny list misses it. On Linux it connects to the local host | Deny 0.0.0.0/8 and ::/128 explicitly |
The whole 127.0.0.0/8 range | Deny lists that only contain the literal 127.0.0.1 | Deny the /8, not the address |
| IPv6 and IPv4-mapped IPv6 | ::1, [::ffff:127.0.0.1] and [::ffff:7f00:1] are the same host in different notations (RFC 4291 section 2.5.5). An IPv4-only deny list sees none of them | Normalise to a 16-byte form, unmap IPv4-mapped addresses back to IPv4, then apply both the IPv4 and IPv6 deny lists |
| Link-local and cloud metadata | 169.254.169.254 is a public-looking address that is not in any RFC 1918 range | Deny 169.254.0.0/16 and fe80::/10 outright, plus provider-specific metadata hostnames |
| Internal DNS names | redis.internal, db.svc.cluster.local and Kubernetes service names resolve only inside the network, so they pass any "is it a valid public hostname" test | Address-based deny lists catch them, because the resolved address is private. Hostname-based checks never will |
| Public wildcard DNS resolvers | Services that resolve an arbitrary encoded address as a subdomain, giving an attacker a real, publicly registered name that resolves to 127.0.0.1 | Same answer: judge the resolved address, never the name |
| Non-HTTP schemes | file://, gopher://, dict:// and ftp:// reach things HTTP cannot. libcurl supports many by default | Allowlist the scheme to http and https before anything else |
Cloud metadata endpoints specifically#
All three major providers use 169.254.169.254, so denying 169.254.0.0/16 covers the address on all of them. The header requirements are defence in depth, not the primary control, but they change what an SSRF must be able to do:
- AWS IMDSv2 requires a
PUTto/latest/api/tokencarryingX-aws-ec2-metadata-token-ttl-seconds, and the returned token must be sent asX-aws-ec2-metadata-tokenon every subsequent request. It rejects token requests that carry anX-Forwarded-Forheader. A plain SSRF that can only issueGETrequests without custom headers therefore fails against IMDSv2 but succeeds against IMDSv1. EnforceHttpTokens: requiredon instances rather than leaving IMDSv1 optional. - The IMDS hop limit is an IP TTL cap on metadata responses, default 1, configurable up to 64. A response with TTL 1 cannot cross a routing hop, so a container on a Docker bridge network cannot reach IMDS at hop limit 1. This is a genuinely useful blast-radius control and it interacts badly with container networking: raising the limit to 2 to make a containerised workload work also re-enables the path an SSRF would use. Prefer per-pod credentials over raising the hop limit.
- GCP requires the header
Metadata-Flavor: Googleon every metadata request and rejects requests without it. - Azure IMDS requires
Metadata: trueand rejects requests carryingX-Forwarded-For.
Angle two: the egress proxy as the control#
The strongest defence is architectural: forbid direct outbound connections from application subnets, and require every outbound HTTP request to go through a forward proxy that has an allowlist and no route to your internal networks.
What this buys, and why it is stronger than in-process validation:
- Resolution moves out of the vulnerable process. With
HTTPS_PROXYset, the client sendsCONNECT api.partner.example:443and the proxy resolves the name. The application's URL parser, its address canonicalisation and its redirect handling stop being security-relevant, because the application never chooses an address. See HTTP CONNECT tunnelling for what the proxy sees at that point. - Policy is centralised and auditable. One allowlist, one log stream, one place to change when a partner endpoint moves, rather than a validation function copied into eleven services.
- The proxy has no internal routes. Put it in a subnet whose route table and security groups permit only the public internet. Even a proxy ACL bug then cannot reach a database, because there is no path.
- It fails closed. A destination not in the allowlist returns
403, which surfaces as a clear application error rather than a silent success against an internal host.
A minimal Squid policy expressing this:
acl to_localhost dst 127.0.0.0/8 0.0.0.0/32 ::1
acl to_internal dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 \
169.254.0.0/16 fc00::/7 fe80::/10
acl SSL_ports port 443
acl app_tier src 10.20.0.0/16
acl allowed_dst dstdomain .partner.example .api.stripe.com
http_access deny to_localhost
http_access deny to_internal
http_access deny CONNECT !SSL_ports
http_access allow app_tier allowed_dst
http_access deny allSquid ships acl to_localhost dst 127.0.0.0/8 0.0.0.0/32 ::1 and http_access deny to_localhost in its default configuration; to_internal is the part you must add. Order matters: http_access rules are evaluated top to bottom and the first match wins, so the deny rules must precede the allow.
Getting in-process validation right when you must#
Where an egress proxy is not available, the only correct shape is to validate at the moment of connection, on the address actually being connected to. In Go, net.Dialer.Control runs after resolution and before connect(2) and receives the concrete address, which closes the TOCTOU window that resolve-then-check leaves open:
dialer := &net.Dialer{
Control: func(network, address string, _ syscall.RawConn) error {
host, _, _ := net.SplitHostPort(address)
ip := net.ParseIP(host)
if ip4 := ip.To4(); ip4 != nil {
ip = ip4 // canonicalise ::ffff:a.b.c.d before comparing
}
if denied(ip) {
return fmt.Errorf("ssrf: destination %s is not permitted", ip)
}
return nil
},
}
client := &http.Client{
Transport: &http.Transport{DialContext: dialer.DialContext},
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse // do not follow redirects
},
Timeout: 10 * time.Second,
}Because Control is invoked for every dial, including each redirect hop if you do follow redirects, and because it sees the resolved address rather than the hostname, it is immune to rebinding, to alternate literal encodings and to internal DNS names. The To4() call is the canonicalisation step from the callout above. Equivalents exist elsewhere: a custom socket factory or resolver hook in Python, and java.net.Socket subclassing or a JVM-level SocketImplFactory in Java.
Failure modes#
502 Bad Gateway with no resolver defined to resolve ... in the nginx error log. A variable in proxy_pass without a resolver directive. The reflex is to add a resolver; check first whether the variable should exist at all, since adding one is what turns the config into an SSRF primitive. Distinguishing this from other gateway errors is covered in 502 vs 503 vs 504.
Allowlist passes in staging, bypassed in production. Typically an IPv6 gap: staging is IPv4-only, production has AAAA records, and the deny list only ever contained IPv4 CIDRs.
Validation succeeds, fetch reaches an internal host, no log of the internal request. Classic rebinding. The application logged the hostname it validated, not the address it connected to. Log the resolved address at connect time, which is also what the reverse proxy security checklist recommends for the edge.
Egress proxy returns 403 Forbidden for a newly added partner. Working as designed. Resist the temptation to widen allowed_dst to a bare domain suffix that also matches a wildcard host you do not control.
Timeouts hide the outcome. An SSRF probe against a closed internal port returns immediately with connection refused, while a filtered port hangs until the timeout. The difference in response time is itself an internal port scan. Set short, uniform outbound timeouts so success and failure are less distinguishable.
Frequently asked questions#
What is SSRF in the context of a reverse proxy?#
It is any configuration where an attacker-supplied value determines the upstream a proxy connects to. The canonical form is proxy_pass with a variable taken from a query argument or a request header. The proxy then makes the request from inside your network, with its own source address and its own reachability, which is usually far greater than the attacker's.
Does validating the hostname against an allowlist prevent SSRF?#
No. Hostname validation is defeated by DNS rebinding, by public wildcard resolvers that map arbitrary addresses into real domain names, and by redirects to a different host after the check. The check has to be on the resolved IP address at the moment of connection, compared against deny CIDRs.
How do I block access to 169.254.169.254 properly?#
Deny the whole 169.254.0.0/16 link-local range and fe80::/10 on the resolved address, not the literal string. Combine that with provider hardening: require IMDSv2 on AWS, keep the metadata hop limit at 1 where container networking allows, and prefer per-workload credentials so the metadata endpoint holds nothing worth stealing.
Should I follow redirects when fetching a user-supplied URL?#
Preferably not. If you must, re-run the full address validation on every hop, including the final one, and cap the hop count. In Go, returning http.ErrUseLastResponse from CheckRedirect disables following; a Dialer.Control hook is a stronger belt-and-braces measure because it is invoked for every dial regardless.
Why is an egress proxy better than validating in the application?#
Because it removes name resolution and destination choice from the vulnerable process. The application asks for a hostname and the proxy decides whether that host may be reached, resolving it itself on a network with no internal routes. Application URL-parsing bugs stop being security-relevant, and policy lives in one auditable place instead of being reimplemented per service.
Can an open forward proxy inside my network be used for SSRF?#
Yes, and it is one of the easier pivots, because the proxy is trusted and its ACLs are frequently permissive on the internal side. Anything that can reach the proxy port can request any address the proxy can route to. The bind address, ACL and CONNECT port restrictions that prevent it are set out in open proxies and misconfigured relays.
Does blocking RFC 1918 ranges cover everything?#
No. A complete deny list also needs 0.0.0.0/8, 127.0.0.0/8, 100.64.0.0/10 (carrier-grade NAT, used by some container and cloud networks), 169.254.0.0/16, 192.0.0.0/24, 198.18.0.0/15, 224.0.0.0/4, 240.0.0.0/4, and on the IPv6 side ::1/128, ::/128, ::ffff:0:0/96, 64:ff9b::/96, fc00::/7 and fe80::/10.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- nginx ngx_http_proxy_module, proxy_pass
- nginx ngx_http_core_module, resolver
- AWS EC2 Instance Metadata Service IMDSv2
- Google Cloud VM metadata server
- Azure Instance Metadata Service
- RFC 6890 Special-Purpose IP Address Registries
- RFC 4291 IP Version 6 Addressing Architecture, section 2.5.5
- Squid http_access and ACL types
- Go net.Dialer Control hook
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.