Safeguard
Security Guides

SSRF Prevention in Go: Blocking Metadata, Redirects, and DNS Rebinding

A single unvalidated URL passed to net/http can hand an attacker your cloud metadata credentials. Here's how SSRF actually works against Go services — and the DialContext-level defense that stops it.

Daniel Osei
Security Researcher
5 min read

Server-side request forgery (SSRF, CWE-918) is the vulnerability that turns a helpful feature into a breach. Your Go service accepts a URL — a webhook target, an image to fetch, a link to preview — and dutifully makes the request from inside your network. An attacker points it at http://169.254.169.254/latest/meta-data/iam/security-credentials/ and your server hands back cloud credentials it had no business fetching. SSRF was the root cause of some of the largest cloud breaches on record, and Go's net/http gives you no protection against it by default: http.Get(userURL) will happily reach any address the host can route to. This guide shows why the naive defenses fail and where the reliable one lives.

Why is validating the URL string not enough?

Because the check and the connection happen at different times and against different things, and attackers exploit the gap.

A common first attempt parses the URL and rejects localhost or 127.0.0.1:

// INSUFFICIENT: string checks are trivially bypassed
u, _ := url.Parse(userURL)
if u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" {
    return errBlocked
}
resp, _ := http.Get(userURL)

Every part of this is bypassable. 127.0.0.1 has thousands of aliases: 0.0.0.0, 0177.0.0.1 (octal), 2130706433 (decimal), [::1], 127.1, and public DNS names that resolve to loopback. The cloud metadata endpoint 169.254.169.254 isn't localhost at all. And even if the hostname looks external at parse time, DNS rebinding lets an attacker's domain resolve to a safe IP during validation and to 169.254.169.254 a moment later when the HTTP client actually dials. You cannot fix SSRF by inspecting the string the user gave you.

Where should the SSRF control actually live?

At the point of connection — inside a custom DialContext that validates the resolved IP right before the socket opens.

net/http lets you supply a Transport whose DialContext you control. That's the one place where you know the real IP the request is about to hit, after DNS resolution, and can refuse it:

func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
    host, port, err := net.SplitHostPort(addr)
    if err != nil {
        return nil, err
    }
    ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
    if err != nil {
        return nil, err
    }
    for _, ip := range ips {
        if !isPublicIP(ip.IP) {
            return nil, fmt.Errorf("blocked non-public address %s", ip.IP)
        }
    }
    // Dial the vetted IP directly to avoid a second, unchecked resolution.
    d := net.Dialer{Timeout: 5 * time.Second}
    return d.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}

The critical detail is the last line: dial the IP you just validated, not the hostname again. If you re-pass the hostname, the resolver can hand the dialer a different (malicious) answer — that's exactly the DNS-rebinding window. Resolve once, validate, connect to that literal IP.

What counts as a "non-public" address to block?

Everything that isn't a globally routable unicast address. Go's net/netip and net.IP helpers make this precise:

func isPublicIP(ip net.IP) bool {
    if ip.IsLoopback() || ip.IsPrivate() ||
        ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
        ip.IsUnspecified() || ip.IsMulticast() {
        return false
    }
    // Explicitly block the cloud metadata address family.
    if ip.Equal(net.ParseIP("169.254.169.254")) {
        return false
    }
    return true
}

ip.IsPrivate() covers RFC 1918 ranges (10/8, 172.16/12, 192.168/16) and the IPv6 unique-local fc00::/7. IsLinkLocalUnicast() covers 169.254.0.0/16 and fe80::/10 — the range the metadata endpoint lives in. Do not forget IPv6: an attacker who can only reach ::ffff:169.254.169.254 (IPv4-mapped) or an IPv6 metadata alias will use it. Test both stacks.

How do redirects reopen the hole you just closed?

An allowed first hop can 302 you straight to a blocked target unless you re-validate every hop.

Your DialContext runs on every connection the client makes, including redirect targets — which is a strong reason to put the control there rather than in a one-time pre-flight check. But belt-and-suspenders: also cap redirects and re-check with CheckRedirect:

client := &http.Client{
    Transport: &http.Transport{DialContext: safeDialContext},
    Timeout:   10 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        if len(via) >= 5 {
            return errors.New("too many redirects")
        }
        return nil // DialContext re-validates the new target's IP
    },
}

Because the validation lives in DialContext, a redirect to 169.254.169.254 still fails at dial time. That layering is what makes the defense robust rather than a single point that a redirect can leapfrog.

What's the full SSRF hardening checklist?

  • Route all user-initiated outbound fetches through one hardened http.Client, never http.Get/http.DefaultClient.
  • Validate the resolved IP in DialContext; dial the validated IP literal, not the hostname.
  • Block loopback, private, link-local, unspecified, and multicast ranges — for IPv4 and IPv6, including IPv4-mapped forms.
  • Cap redirects and rely on DialContext to re-validate each hop.
  • Set aggressive timeouts and disable protocols you don't need (reject non-http/https schemes like file://, gopher://).
  • Prefer allowlisting destination hosts over denylisting internal ones when the use case permits it.
  • Bind cloud workloads to IMDSv2 (session-token required) so a bare GET to the metadata IP fails even if a request slips through.

How Safeguard Helps

SSRF is a runtime behavior, which is why it's easy for static review to miss and hard for it to confirm. Safeguard's dynamic application security testing exercises your running Go service with SSRF payloads — metadata IPs, encoded loopback forms, redirect chains — and reports which endpoints actually make the forbidden request, not just which ones might. Software composition analysis flags vulnerable HTTP-client and URL-parsing dependencies in the same pass, and Griffin, the AI analysis engine, turns each confirmed finding into a DialContext-level fix you can drop in. For a broader view of the runtime controls, see our Go web application security checklist.

Test your Go endpoints for SSRF free at app.safeguard.sh/register, with setup docs at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.