Whenever your backend fetches a URL that a user influenced — webhook targets, import-from-URL features, URL previews, PDF generators, RSS readers — your server becomes a proxy the attacker can aim anywhere. The request originates inside your network, from a machine that can reach databases, admin panels, and cloud metadata endpoints that no external client should ever touch. That is server-side request forgery, ranked as A10 in the OWASP Top 10 since 2021, and defending it well is subtler than “block private IPs,” because the two most common defenses each have a hole that turns them into no-ops.
This post covers the whole defense in Go: why hostname allowlists fail, how DNS rebinding defeats resolve-then-check validation, why redirects bypass your checks entirely, and how to build an HTTP client whose dialer validates the actual destination IP on every connection — plus the cloud metadata hardening that limits the blast radius when something slips through. The official prevention guidance organizes defenses by deployment case; the client below implements the strictest of them.
The threat model in one paragraph
The attacker does not need network access to your infrastructure — they need one feature where your server fetches a URL they control or influence. The classic targets: internal services on 10.x addresses, admin panels bound to localhost, and above all the cloud metadata service at 169.254.169.254, which hands out instance credentials to anything on the box. The Capital One breach in 2019 followed exactly this path — SSRF to metadata, extracted role credentials, S3 exfiltration. Everything downstream is volume, not novelty: the SSRF is the entry, and it starts with a single unchecked fetch.
Why the obvious defenses fail
Checking the hostname string. The most common implementation parses the URL, checks whether the host looks internal, and fetches if it passes. Three problems: hosts can be written as raw IPs in unusual encodings, IPv6 can carry an IPv4 address inside it (the ::ffff: mapped form), and the check runs against the hostname rather than the address your machine will actually connect to.
Resolving, then checking, then fetching. This fixes the encoding problem but introduces the classic time-of-check-to-time-of-use race: validation and connection are two separate DNS lookups. An attacker with a domain they control can answer the first lookup with a safe public IP and the second with 169.254.169.254. This is DNS rebinding, and it defeats every defense that validates the IP from one resolution and then lets the HTTP client resolve again. Even a domain allowlist does not prevent rebinding — a rebinding domain sits at the attacker’s own hostname, which may be perfectly allowlisted.
Forgetting redirects. If your validator examines the original URL but your HTTP client follows redirects with default settings, the attacker needs one more trick: a 302 from their checked domain to an internal address. Go’s http.Client follows up to ten redirects by default, and every redirect target gets a fresh connection and a fresh DNS resolution — your validation logic never sees any of them.
The defense: validate at dial time, every time
The structural fix is to move validation to the only point that cannot race or be bypassed: the moment the TCP connection is dialed, against the already-resolved IP. Go’s net.Dialer.ControlContext hook runs for every connection with the resolved address in hand, regardless of how many DNS lookups or redirects preceded it. Deny-by-default there, and rebinding loses its window because validation and connection happen atomically.
package safefetch
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"syscall"
"time"
)
var blocked = []netip.Prefix{
netip.MustParsePrefix("127.0.0.0/8"), // loopback
netip.MustParsePrefix("10.0.0.0/8"), // private
netip.MustParsePrefix("172.16.0.0/12"), // private
netip.MustParsePrefix("192.168.0.0/16"), // private
netip.MustParsePrefix("169.254.0.0/16"), // link-local incl. cloud metadata
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
netip.MustParsePrefix("::1/128"), // IPv6 loopback
netip.MustParsePrefix("fe80::/10"), // IPv6 link-local
netip.MustParsePrefix("fc00::/7"), // IPv6 unique-local
}
func isBlocked(ip netip.Addr) bool {
ip = ip.Unmap() // fold ::ffff:10.0.0.5 back into IPv4 space
for _, p := range blocked {
if p.Contains(ip) {
return true
}
}
return false
}
func SafeClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
ControlContext: func(_ context.Context, _, 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 isBlocked(ip) {
return fmt.Errorf("connection to %s blocked by SSRF policy", ip)
}
return nil
},
}
return &http.Client{
Transport: &http.Transport{DialContext: dialer.DialContext},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("stopped after 3 redirects")
}
return nil // each hop opens a new dial, and the dialer re-validates
},
}
}
The redirect handler caps the chain rather than disabling it, because each redirect triggers a new dial — and the dialer re-validates the new destination against the same blocklist. A redirect to an internal address gets refused mid-flight. If your use case never legitimately needs redirects (most importers and fetchers do not), return an error from CheckRedirect unconditionally and simplify the picture.
Layering the allowlist on top
The blocklist above is a floor, not the whole policy. When the set of legitimate destinations is small and known — webhooks to your own relay, imports from a partner’s fixed endpoint — an allowlist beats a blocklist by construction. Enforce it at dial time too, keyed on the IP that was actually resolved, so a rebinding domain cannot impersonate an allowlisted one:
package safefetch
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"syscall"
"time"
)
func AllowlistedClient(allowedIPs []netip.Prefix) *http.Client {
client := SafeClient()
client.Transport = &http.Transport{
DialContext: allowedDialer(allowedIPs).DialContext,
}
return client
}
func allowedDialer(allowedIPs []netip.Prefix) *net.Dialer {
return &net.Dialer{
Timeout: 5 * time.Second,
ControlContext: func(_ context.Context, _, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := netip.MustParseAddr(host).Unmap()
if isBlocked(ip) {
return fmt.Errorf("connection to %s blocked by SSRF policy", ip)
}
for _, p := range allowedIPs {
if p.Contains(ip) {
return nil
}
}
return fmt.Errorf("destination %s not in allowlist", ip)
},
}
}
Both checks now run in one hook: the deny-by-default blocklist first, the allowlist second, then the dial. That ordering matters — an allowlist entry with an overbroad prefix cannot re-open private ranges, because the blocklist refuses them before the allowlist is ever consulted.
Cloud metadata: assume some requests get through
Defense in depth means shrinking the value of any SSRF that still lands. The metadata service is the prize, and every major cloud has added hardening specifically against it. On AWS, IMDSv2 requires a session token obtained by a PUT request — a plain GET gets nothing — and the token must be attached as a header on the real fetch. Because PUT-based token acquisition is awkward to trigger through many SSRF-prone code paths, and because IMDSv2 responses use a network hop limit of 1 so they cannot be routed off the instance, the practical payoff of an SSRF against an IMDSv2-only instance drops sharply. Enforce it with the metadata options flag on the instance or in your launch template; treating it as a default-on setting rather than a hardening option is the right posture for anything internet-facing.
Equivalent controls exist elsewhere — GCP requires a Metadata-Flavor header that naive HTTP libraries will not send, and Azure’s metadata endpoints require an explicit Metadata: true header that naive HTTP libraries will not send — but AWS’s hop-limit mechanism is the one specifically engineered to make off-box exploitation fail.
Putting the checklist together
- Parse the user-supplied URL yourself; allow only http and https schemes with an explicit port or port 80/443.
- Validate nothing by hostname alone — deny-by-default IP validation at dial time via ControlContext, folding IPv4-mapped IPv6 addresses back.
- Cap or forbid redirects; the dialer covers any you permit.
- Add an allowlist of resolved IPs when legitimate destinations are enumerable.
- Enforce IMDSv2 on every instance that runs your fetchers, and scope instance roles so metadata credentials are minimally powerful.
- Log refused dial attempts with the destination IP — a spike means someone is probing.
The pattern generalizes beyond Go: any stack that separates “check the URL” from “open the socket” has a rebinding window. Whatever language you fetch in, find the hook that runs at connect time with the resolved address, and put your policy there. Everything upstream of that is suggestion; the dial is the moment of truth.