SSRF Defense in Go: Why URL Validation Fails and How to Fix It at the Dial Layer

Server-Side Request Forgery (SSRF) keeps topping real-world breach reports for a simple reason: modern applications are glued together with outbound HTTP calls. Webhooks, URL previews, PDF renderers, image proxies, OAuth flows, “import from URL” features — every one of them takes a URL from somewhere and makes your server fetch it. If an attacker can influence that URL, they can often make your server talk to itself, to your internal network, or to the cloud metadata service that hands out credentials.

CWE-918 is the formal definition, but the pattern needs no formal study to exploit. This post walks through why the naive defenses fail — including the surprisingly subtle failure of “just resolve the hostname and check the IP” — and then builds a URL allow-listing fetcher in Go that closes the main attack paths.

What an SSRF actually buys an attacker

The classic target is the cloud metadata endpoint. On AWS, a request from inside an EC2 instance to 169.254.169.254 returns instance credentials for attached IAM roles. An SSRF that reaches it turns a harmless “fetch this image URL” feature into a credential theft. GCP’s metadata.google.internal and Azure’s equivalent service work the same way. Internal services are the second prize: databases, admin panels, Kubernetes APIs, and Redis instances usually sit on RFC1918 ranges with no authentication between services, because “who could even reach them?”

The third prize is the application itself. A request to http://localhost:8080/admin/delete-user bypasses every network-level control, because it originates from the trusted host. Response-based attacks don’t even need the response returned to the attacker — timing and error messages leak plenty.

Why the obvious defenses fail

Blocklists of hostnames fail on DNS. You block 169.254.169.254, so the attacker uses a domain they control that resolves to it — or a rebinding setup where the first DNS answer is a public IP and the second is the internal one. Deny-lists are bypass-prone by design; the OWASP SSRF prevention cheat sheet treats them strictly as a last resort and lists the minimum ranges to block when you must: link-local 169.254.0.0/16, loopback 127.0.0.0/8, RFC1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and the IPv6 equivalents.

Resolve-then-check fails on time-of-check to time-of-use. The tempting fix is: parse the URL, resolve the hostname, verify the IP is public, then connect. But between your LookupHost call and the HTTP client’s actual connection, DNS can answer differently. The resolver you called is not the resolver the client uses. Every check performed before the connection is a check the attacker can route around.

Following redirects reopens the door. Your code validates https://attacker.example/seed.png, it’s a public IP, all good — then the server responds with 302 Location: http://169.254.169.254/latest/meta-data/ and your HTTP client, politely following redirects, makes the request you spent all that effort preventing.

URL parsers disagree with each other. What does the URL http://allowedsite.com@evil.example/ point to? Depends which parser you ask. Ambiguous components — userinfo, encoded characters, backslashes, trailing dots — mean the string you validated and the URL the client connects to can be different resources. Validate once, at one choke point, with one parser.

The fix: validate inside the dial

The robust pattern is to make the check and the connection atomic. Go’s net.Dialer has a Control hook that fires after the socket is created but before it connects, handing you the resolved address as a syscall.RawConn parameter. You inspect the actual IP the connection will use — not a DNS answer you fetched separately — and reject anything that isn’t allowed. Combined with disabling redirects and locking the scheme and port, this closes the TOCTOU gap and the redirect gap in one place:

package fetch

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"strings"
	"syscall"
	"time"
)

var ErrBlockedHost = errors.New("host resolved to a non-public address")

// SafeTransport returns an *http.Transport that refuses to connect to
// anything except public IPs. The check runs in Dialer.Control, on the
// exact address about to be dialed.
func SafeTransport() *http.Transport {
	dialer := &net.Dialer{
		Timeout: 5 * time.Second,
		Control: func(network, address string, _ syscall.RawConn) error {
			host, _, err := net.SplitHostPort(address)
			if err != nil {
				return fmt.Errorf("bad address %q: %w", address, err)
			}
			ip := net.ParseIP(host)
			if ip == nil {
				return fmt.Errorf("not an IP: %q", host)
			}
			if !isPublic(ip) {
				return ErrBlockedHost
			}
			return nil
		},
	}
	return &http.Transport{
		DialContext:           dialer.DialContext,
		TLSHandshakeTimeout:   5 * time.Second,
		ResponseHeaderTimeout: 10 * time.Second,
		MaxIdleConns:          10,
	}
}

func isPublic(ip net.IP) bool {
	// Unspecified/loopback/link-local/private/multicast are all off limits.
	if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
		ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsMulticast() {
		return false
	}
	return true
}

// SafeFetch validates the URL, then fetches it with redirect-following off.
func SafeFetch(client *http.Client, rawURL string) (*http.Response, error) {
	u, err := url.Parse(rawURL)
	if err != nil {
		return nil, fmt.Errorf("unparseable URL: %w", err)
	}
	if u.Scheme != "https" {
		return nil, fmt.Errorf("only https is allowed, got %q", u.Scheme)
	}
	if u.Port() != "" && u.Port() != "443" {
		return nil, fmt.Errorf("only port 443 is allowed, got %q", u.Port())
	}
	// Reject userinfo tricks: https://allowedsite.com@evil.example/
	if u.User != nil {
		return nil, errors.New("userinfo in URL is not allowed")
	}
	host := strings.TrimSuffix(u.Hostname(), ".")
	if host == "" {
		return nil, errors.New("missing host")
	}

	resp, err := client.Get(u.String())
	if err != nil {
		var blocked *url.Error
		if errors.As(err, &blocked) && errors.Is(blocked, ErrBlockedHost) {
			return nil, ErrBlockedHost
		}
		return nil, err
	}
	return resp, nil
}

The transport-level Control check re-fires on every hop, because each redirect causes a new dial. That’s the quiet superpower of checking at the dial layer: it applies to every connection the client makes, however the URL got into the queue. Even with CheckRedirect returning http.ErrUseLastResponse, keeping the dial check active means a future refactor that re-enables redirect following doesn’t silently reopen the hole.

Wire it together with redirects disabled:

package fetch

import (
	"net/http"
	"time"
)

func NewSafeClient() *http.Client {
	return &http.Client{
		Transport: SafeTransport(),
		Timeout:   15 * time.Second,
		// The default policy follows up to 10 redirects. Turn it off:
		// a redirect can point anywhere, including the metadata service.
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
}

If your feature genuinely must follow redirects (an image proxy often should), don’t just re-run the URL validation — the transport-level Control check already re-fires on every hop, because each redirect causes a new dial. That’s the quiet superpower of checking at the dial layer: it applies to every connection the client makes, however the URL got into the queue.

Defense in depth beyond the code

  • Egress firewall rules. The single highest-leverage control is network-level: the instance that fetches user URLs should not be able to reach 169.254.169.254, RFC1918 ranges, or your internal service ports at all. Application checks can have bugs; a security-group rule that denies the metadata range doesn’t.
  • IMDSv2 on AWS. The token-based Instance Metadata Service requires a PUT request with a session token header before it answers, which most SSRF payloads won’t produce. Enable it and set the hop limit to 1. The OWASP cheat sheet calls this out as the defense-in-depth layer for cloud credentials.
  • Separate network namespace or service. Run the URL-fetching feature in its own container with its own egress policy. Blast radius matters more than elegance.
  • Response handling discipline. Don’t echo raw response bodies back to users, cap response size before reading it, and enforce content-type checks. A fetcher that happily proxies text/html from an internal admin panel is an open proxy with extra steps.

Wrapping up

SSRF defense fails when validation and connection are separate steps, because DNS and redirects give attackers a way to make those steps disagree. The fix is structural: check the resolved IP inside the dial path, disable or re-validate redirects, restrict scheme and port, and back it all with egress rules that make the dangerous destinations unreachable regardless of application bugs. Build the fetcher once, carefully, and route every user-influenced URL through it — ad-hoc http.Get calls sprinkled through a codebase are how these bugs ship.

Leave a Reply

Your email address will not be published. Required fields are marked *