The Anatomy of Modern SSRF Attacks

Server-Side Request Forgery (SSRF) remains one of the most devastating vulnerabilities in microservice architectures and cloud environments. Whenever a service accepts an external URL—whether fetching webhooks, generating link previews, crawling user-supplied websites, or downloading cloud storage assets—an attacker can coerce the backend server into sending unauthorized requests across internal networks.

In cloud infrastructures like AWS, GCP, or Azure, the target is often the Instance Metadata Service (IMDS) located at 169.254.169.254. In containerized environments, internal services behind API gateways (such as HashiCorp Consul, etcd, Redis, or Kubernetes kubelet APIs) often omit internal authentication under the assumption that the private network perimeter is trusted.

Why URL Parsing and Application-Level Blacklists Fail

Most naive implementations attempt to validate URLs before passing them to standard HTTP client libraries. A typical flawed defense checks the domain name or parsed IP address against a denylist using standard string manipulation or regex:

// FLAGGED: Flawed defense vulnerable to TOCTOU and DNS Rebinding
func isSafeURL(rawURL string) bool {
    u, err := url.Parse(rawURL)
    if err != nil {
        return false
    }
    ips, _ := net.LookupIP(u.Hostname())
    for _, ip := range ips {
        if ip.IsPrivate() || ip.IsLoopback() {
            return false // Detected private IP
        }
    }
    return true
}

// Client executes request milliseconds later
resp, err := http.Get(targetURL)

This validation pattern introduces two fatal vulnerabilities:

  • Time-of-Check to Time-of-Use (TOCTOU) & DNS Rebinding: The attacker configures a DNS authoritative nameserver with a TTL of 0 seconds. On the first lookup during net.LookupIP(), the server responds with a benign public IP address (e.g., 93.184.216.34). When http.Get() initiates the actual TCP connection moments later, the OS resolver re-queries the authoritative server, which now returns 169.254.169.254 or 127.0.0.1. The request bypasses the initial guard entirely.
  • IP Obfuscation and Encoded Representations: Attackers can bypass naive string parsers using alternative IP representations: decimal format (http://2130706433 for 127.0.0.1), octal notation (http://0177.0.0.1), or IPv4-mapped IPv6 addresses ([::ffff:127.0.0.1]).
  • Unchecked HTTP Redirects: An allowed public endpoint can issue an HTTP 302 Found redirect pointing to http://169.254.169.254/latest/meta-data/. If the HTTP client follows redirects automatically, the safety boundary is shattered.

Defensive Engineering: Socket-Level Validation

The only reliable SSRF defense is to hook into the transport layer at connection establishment. By intercepting the socket dialer, you inspect the exact IP address chosen by the operating system after DNS resolution has finished, immediately before the TCP SYN handshake begins.

Implementing an SSRF-Safe Transport in Go

In Go, this is achieved by customizing http.Transport and utilizing the Control hook on net.Dialer, alongside enforcing strict redirect policies.

package ssrfsafe

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

var reservedCIDRs = []netip.Prefix{
	netip.MustParsePrefix("0.0.0.0/8"),       // Current network
	netip.MustParsePrefix("10.0.0.0/8"),      // RFC 1918 Private
	netip.MustParsePrefix("100.64.0.0/10"),   // Shared Address Space (Carrier-grade NAT)
	netip.MustParsePrefix("127.0.0.0/8"),     // Loopback
	netip.MustParsePrefix("169.254.0.0/16"),  // Link-Local (AWS/GCP IMDS)
	netip.MustParsePrefix("172.16.0.0/12"),   // RFC 1918 Private
	netip.MustParsePrefix("192.168.0.0/16"),  // RFC 1918 Private
	netip.MustParsePrefix("::1/128"),         // IPv6 Loopback
	netip.MustParsePrefix("fc00::/7"),        // IPv6 Unique Local
	netip.MustParsePrefix("fe80::/10"),       // IPv6 Link-Local
}

var ErrForbiddenIP = errors.New("access to private/reserved IP is blocked")

func isBlocked(addr netip.Addr) bool {
	// Unmap IPv4-in-IPv6 addresses (e.g. ::ffff:127.0.0.1 -> 127.0.0.1)
	unmapped := addr.Unmap()
	for _, prefix := range reservedCIDRs {
		if prefix.Contains(unmapped) {
			return true
		}
	}
	return false
}

// NewSafeHTTPClient returns an http.Client immune to DNS rebinding SSRF
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
	dialer := &net.Dialer{
		Timeout:   timeout,
		KeepAlive: 30 * time.Second,
		Control: func(network, address string, c syscall.RawConn) error {
			host, _, err := net.SplitHostPort(address)
			if err != nil {
				return fmt.Errorf("invalid address: %w", err)
			}

			ip, err := netip.ParseAddr(host)
			if err != nil {
				return fmt.Errorf("failed to parse resolved IP: %w", err)
			}

			if isBlocked(ip) {
				return fmt.Errorf("%w: %s", ErrForbiddenIP, ip.String())
			}

			return nil
		},
	}

	transport := &http.Transport{
		DialContext:           dialer.DialContext,
		ForceAttemptHTTP2:     true,
		MaxIdleConns:          100,
		IdleConnTimeout:       90 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
	}

	return &http.Client{
		Transport: transport,
		Timeout:   timeout,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) >= 5 {
				return errors.New("stopped after 5 redirects")
			}
			// Enforce HTTPS-only redirects if strict security is desired
			if req.URL.Scheme != "https" {
				return errors.New("redirect to insecure protocol rejected")
			}
			return nil
		},
	}
}

How This Solves the Vulnerabilities

  1. DNS Rebinding Neutralized: The Control hook receives the final IP chosen by the OS socket implementation just before the connect syscall executes. If the authoritative nameserver rotated the resolution to 127.0.0.1 or 169.254.169.254, the isBlocked(ip) check evaluates true, returning an error that aborts the socket creation immediately.
  2. IPv6 Canonicalization via netip.Addr.Unmap(): The netip package automatically unwraps dual-stack mapped addresses. An attacker supplying ::ffff:169.254.169.254 is converted to IPv4 169.254.169.254, where it matches the link-local prefix.
  3. Redirect Interception: When the client receives a redirect header (e.g. Location: http://internal.corp/secret), the request triggers a new connection through the same hardened transport, re-evaluating the resolved IP on every hop.

Production Gotchas and Edge Cases

Defensive engineering requires anticipating operational failure modes when introducing network barriers.

1. Connection Pooling & Persistent Keep-Alives

The Control hook only runs on a fresh TCP dial. Once a connection enters the http.Transport idle pool, subsequent requests reuse the open socket without invoking the dialer again. While safe for honest hostnames, ensure you set explicit, short IdleConnTimeout values (e.g., 30–60 seconds) when handling untrusted URLs so stale sockets do not stay open indefinitely.

2. Response Smuggling via Chunked Encoding

An attacker-controlled server can return an infinite stream of bytes or extremely slow chunks (Slowloris response). Always wrap outbound calls with a strict context.WithTimeout and restrict max response size using io.LimitReader:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, untrustedURL, nil)
resp, err := client.Do(req)
if err != nil {
    return nil, err
}
defer resp.Body.Close()

// Cap response reading to 2MB to prevent memory exhaustion
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))

Key Takeaways

  • Application-level URL validations and string checks are insufficient against SSRF due to DNS rebinding, alternative IP formats, and HTTP redirects.
  • Intercept the connection directly at the transport layer using socket-level hooks (like Go's net.Dialer.Control) to inspect the post-resolution IP address immediately before connection.
  • Normalize IPv6 dual-stack mappings using netip.Addr.Unmap() to prevent IPv4-mapped IPv6 evasion techniques.
  • Combine custom transport verification with strict redirect counters, HTTPS-only rules, and explicit io.LimitReader boundaries to prevent stream-based resource exhaustion.