Why the obvious defenses fail, and how to build an http.Client that doesn’t
Server-Side Request Forgery (SSRF) is the bug where an attacker makes your server issue a request they choose. The classic prize is the cloud metadata
service at 169.254.169.254: a single fetch can hand over temporary IAM
credentials. This is not theoretical — it was the pivot in the 2019 Capital One
breach. Anywhere your backend takes a URL from a user — webhooks, link
previews, avatar-by-URL, PDF renderers, import-from-URL — is a candidate.
The hard part of SSRF is not spotting it. It is that the defenses people reach for first are the ones attackers expect.
The trap #1: blocklisting “localhost”
The first instinct is to reject obvious internal targets:
// DON'T: a blocklist of "bad" hostnames
func isInternal(host string) bool {
bad := []string{"localhost", "127.0.0.1", "169.254.169.254"}
for _, b := range bad {
if host == b {
return true
}
}
return false
} 127.0.0.1 has a dozen spellings that all reach the loopback interface, and a
blocklist matches none of them:
http://127.0.0.1 # the one you blocked
http://127.1 # shorthand, still loopback
http://2130706433 # 127.0.0.1 as a decimal integer
http://0x7f000001 # ...as hex
http://[::1] # IPv6 loopback
http://[::ffff:127.0.0.1] # IPv4-mapped IPv6
http://0.0.0.0 # "this host" on many stacks A blocklist is a guess at every bad value. You will lose that game.
The trap #2: a host allowlist checked too early
The right idea is an allowlist: only let the app reach hosts you approve. But where you check it matters. This looks safe and is not:
// DON'T: validate the hostname, then fetch by hostname
func fetch(rawURL string) (*http.Response, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if !allowedHosts[u.Hostname()] {
return nil, errors.New("host not allowed")
}
// ... time passes ...
return http.Get(rawURL) // a *second*, independent DNS lookup
} Two problems live here.
DNS rebinding (a TOCTOU bug). Your check resolves evil.example and sees a
public IP. It passes. Then http.Get resolves the same name again — and the
attacker’s DNS server, with a 0-second TTL, now answers 127.0.0.1. You
validated one thing and connected to another. The hostname was never the
asset worth checking; the IP you actually connect to is.
Redirects. Even if the first hop is a clean allowlisted host, the server
can answer 302 Location: http://169.254.169.254/... and Go’s default client
will happily follow it.
The fix: validate the resolved IP at dial time
The only check that cannot be raced is the one performed against the concrete
IP, immediately before the socket connects. Go gives us exactly that hook: net.Dialer.Control runs after name resolution and before connect(2),
and it receives the resolved ip:port. DNS rebinding has nothing left to flip:
whatever the name resolved to, this is the address we are about to talk to.
package safehttp
import (
"context"
"fmt"
"net"
"net/http"
"syscall"
"time"
)
// isDisallowedIP reports whether ip points somewhere a server-side fetcher
// must never reach: loopback, RFC 1918 private space, link-local
// (including the cloud metadata address 169.254.169.254), the unspecified
// address and multicast. IPv4-mapped IPv6 is handled because the net.IP
// methods normalise via To4 internally.
func isDisallowedIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast()
}
// safeControl runs once the address has been resolved, just before connect.
// address is "ip:port" with the *resolved* IP, so a rebinding attack can't
// sneak a public name past validation and then point it at the loopback.
func safeControl(network, address string, _ syscall.RawConn) error {
if network != "tcp4" && network != "tcp6" {
return fmt.Errorf("network %q not allowed", network)
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("could not parse IP from %q", address)
}
if isDisallowedIP(ip) {
return fmt.Errorf("blocked connection to disallowed IP %s", ip)
}
return nil
}
// NewSafeClient returns an http.Client hardened against SSRF.
func NewSafeClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
Control: safeControl,
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
// Ignore HTTP_PROXY/HTTPS_PROXY: a proxy would tunnel the request
// and our dial-time IP check would never run.
Proxy: nil,
}
return &http.Client{
Timeout: 15 * time.Second,
Transport: transport,
// Refuse to follow redirects. Control would re-run on the redirect's
// dial and still block an internal IP, but stopping here removes a
// whole class of redirect-driven surprises and keeps behaviour
// predictable. Inspect resp.Location yourself if you must follow.
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
} The key property: Control fires on every dial, including the dials made
for redirects and for new connections in the pool. There is no window between
“checked” and “connected” for an attacker to exploit.
Layer it, don’t rely on one thing
Dial-time IP filtering is the backstop, not the whole strategy. Combine it with checks that encode your business rules:
func FetchPreview(client *http.Client, rawURL string) ([]byte, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
// 1. Scheme allowlist — no file://, gopher://, dict:// ...
if u.Scheme != "https" {
return nil, errors.New("only https is allowed")
}
// 2. Optional host allowlist for business logic (NOT the security boundary)
if !allowedHosts[u.Hostname()] {
return nil, errors.New("host not allowed")
}
// 3. The hard backstop: the safe client blocks internal IPs at dial time
resp, err := client.Get(u.String())
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 4. Cap the response so a malicious target can't exhaust memory
return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB
} A quick test makes the guarantee visible and keeps it from regressing:
func TestSafeClientBlocksMetadata(t *testing.T) {
client := safehttp.NewSafeClient()
_, err := client.Get("http://169.254.169.254/latest/meta-data/")
if err == nil {
t.Fatal("expected the metadata endpoint to be blocked")
}
} The checklist
- Validate the IP at dial time (
Dialer.Control), not the hostname earlier — this is what kills DNS rebinding. - Block loopback, private, link-local, unspecified and multicast ranges.
- HTTPS-only scheme allowlist; reject
file://,gopher://, and friends. - Disallow redirects, or re-validate every hop.
- Set timeouts everywhere and cap the response body with
io.LimitReader. - Ignore proxy environment variables on the fetcher’s transport.
- Don’t reflect raw fetch errors or bodies back to the user — that turns blind SSRF into an oracle.
- For real isolation, put outbound fetches in their own network segment (k8s
NetworkPolicy, security groups) so the metadata service is unreachable at the network layer too.
If you would rather not hand-roll this, doyensec/safeurl packages the same dial-time approach. Either way, the principle is the one worth keeping: enforce the rule at the boundary where the action actually happens. A hostname is a promise; the dialed IP is the truth.