Frontend Labs
⋯

Chapter 22 — Outbound requests: SSRF and safe HTTP clients

In one sentence

Whenever your server fetches a URL that a user influenced, it must check the IP address it actually connects to, after DNS resolution and on every redirect, or the user can make your server reach things only it can reach.

Why this matters

Webhooks, link previews, "import from URL", PDF renderers, and image proxies all fetch user-supplied URLs. Your server sits inside your network, next to databases, admin panels, and the cloud metadata service at 169.254.169.254, which hands out the instance's cloud credentials. Server-side request forgery (SSRF) was behind major breaches, including the 2019 Capital One breach, where an SSRF reached the AWS metadata service. It's in the OWASP Top 10.

How it actually works

String checks on the URL fail, because there are too many ways to name the same address:

http://169.254.169.254/              the obvious one
http://[::ffff:7f00:1]/              127.0.0.1 as an IPv4-mapped IPv6 address
http://0.0.0.0:6379/                 "any address" reaches local services on many OSes
http://localtest.me/                 a PUBLIC DNS name that resolves to 127.0.0.1
http://attacker.example/             resolves to a public IP at check time, 127.0.0.1 at fetch time (DNS rebinding)
https://public.example/redirect      a 302 to http://169.254.169.254/

The reliable check happens at connect time. Go's net.Dialer.Control hook runs after DNS resolution and immediately before connect(), receiving the literal IP:port. Every connection the client makes goes through it, including each redirect.

Cloud defences add a second layer: IMDSv2 on AWS requires a session token obtained with a PUT request that has a hop limit of 1, which defeats most SSRF. Enable it.

Build it in Go (30 min) — an SSRF-safe client

// ssrfguard: an HTTP client that is safe to point at user-supplied URLs.
//
// Server-side request forgery (SSRF): your server fetches a URL a user gave
// you (webhooks, link previews, image import) and the user gives you
// http://169.254.169.254/ (cloud metadata credentials) or http://localhost:6379/.
// Checking the URL STRING is not enough -- DNS names, redirects, and odd IP
// spellings get around it. Check the IP address at the moment of connecting.
//
//	go run ./ssrfguard
package main

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

var errBlocked = errors.New("blocked: destination is not a public address")

// publicOnly runs AFTER DNS resolution and BEFORE connect(), for every
// connection the client makes -- including each redirect hop. `address` is
// the literal IP:port being dialled, so there is nothing left to trick.
func publicOnly(network, address string, _ syscall.RawConn) error {
	ap, err := netip.ParseAddrPort(address)
	if err != nil {
		return errBlocked
	}
	ip := ap.Addr().Unmap() // ::ffff:127.0.0.1 is 127.0.0.1
	switch {
	case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), // 127/8, 10/8, 172.16/12, 192.168/16, fc00::/7, 169.254/16
		ip.IsLinkLocalMulticast(), ip.IsInterfaceLocalMulticast(), ip.IsMulticast(),
		ip.IsUnspecified(), // 0.0.0.0 reaches localhost on many systems
		cgnat.Contains(ip):
		return fmt.Errorf("%w (%s)", errBlocked, ip)
	}
	if ap.Port() != 80 && ap.Port() != 443 {
		return fmt.Errorf("%w (port %d)", errBlocked, ap.Port())
	}
	return nil
}

var cgnat = netip.MustParsePrefix("100.64.0.0/10")

func safeClient() *http.Client {
	dialer := &net.Dialer{Timeout: 3 * time.Second, Control: publicOnly}
	return &http.Client{
		Timeout: 5 * time.Second,
		Transport: &http.Transport{
			DialContext: dialer.DialContext,
			Proxy:       nil, // an environment proxy would dial on our behalf, bypassing the check
		},
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) >= 3 {
				return errors.New("too many redirects")
			}
			return nil // each hop is dialled through publicOnly again
		},
	}
}

// naiveCheck is what people write first. Every line of the demo defeats it.
func naiveCheck(raw string) bool {
	for _, bad := range []string{"localhost", "127.0.0.1", "169.254.169.254", "10.", "192.168."} {
		if strings.Contains(raw, bad) {
			return false
		}
	}
	return true
}

func main() {
	internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "SECRET internal admin data")
	}))
	defer internal.Close()
	_, port, _ := net.SplitHostPort(strings.TrimPrefix(internal.URL, "http://"))

	tests := []string{
		"https://example.com/",                     // fine
		"http://169.254.169.254/latest/meta-data/", // cloud metadata endpoint
		"http://[::ffff:7f00:1]:" + port + "/",     // 127.0.0.1, spelled as IPv4-mapped IPv6
		"http://0.0.0.0:" + port + "/",             // "any" address reaches local services
		"http://localtest.me:" + port + "/",        // a public DNS name that resolves to 127.0.0.1
		// A public URL that REDIRECTS to the metadata service (URL-encoded, so
		// the string check can't see it). The first hop is allowed; the second is not.
		"https://httpbin.org/redirect-to?url=http%3A%2F%2F%31%36%39.254.169.254%2F",
	}
	c := safeClient()
	for _, u := range tests {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
		resp, err := c.Do(req)
		cancel()
		verdict := "ALLOWED"
		if err != nil {
			verdict = "BLOCKED"
			if !errors.Is(err, errBlocked) {
				verdict = "ERROR  "
			}
		} else {
			resp.Body.Close()
		}
		short := u
		if len(short) > 46 {
			short = short[:46] + "..."
		}
		fmt.Printf("%-50s naive=%-5v  guarded=%s", short, naiveCheck(u), verdict)
		if err != nil {
			msg := err.Error()
			if i := strings.Index(msg, "blocked:"); i >= 0 {
				msg = msg[i:]
			}
			fmt.Printf("  (%s)", msg)
		}
		fmt.Println()
	}
}
$ go run ./ssrfguard
https://example.com/                               naive=true   guarded=ALLOWED
http://169.254.169.254/latest/meta-data/           naive=false  guarded=BLOCKED  (blocked: destination is not a public address (169.254.169.254))
http://[::ffff:7f00:1]:57786/                      naive=true   guarded=BLOCKED  (blocked: destination is not a public address (127.0.0.1))
http://0.0.0.0:57786/                              naive=true   guarded=BLOCKED  (blocked: destination is not a public address (0.0.0.0))
http://localtest.me:57786/                         naive=true   guarded=BLOCKED  (blocked: destination is not a public address (::1))
https://httpbin.org/redirect-to?url=http%3A%2F...  naive=true   guarded=BLOCKED  (blocked: destination is not a public address (169.254.169.254))

What to notice:

  • The naive string check let four of the five attacks through (naive=true). The connect-time check blocked all of them.
  • The redirect case: the first hop (httpbin.org, a public address) was allowed. The redirect target was blocked when the client dialled it. Checking only the initial URL would have missed it.
  • localtest.me resolved to ::1: the check works on whatever address DNS returns, including IPv6, so DNS rebinding gains nothing.
  • Proxy: nil: if the transport used an HTTP proxy from the environment, the proxy would dial the target, and our check would only see the proxy's address.
  • Ports are restricted to 80/443, so a user can't make you talk to Redis on 6379 even on a public IP.

Exercises:

  1. Add a response size limit (io.LimitReader) and a content-type allow-list. SSRF defences also need to stop your server downloading 10 GB or HTML when it expected an image.
  2. Add an allow-list mode for webhooks: only domains a customer has verified.
  3. Log every blocked attempt with the user ID. An SSRF probe is a security event worth alerting on.

Real-world scenario

A "fetch a thumbnail for this link" feature validates that the URL's host isn't localhost or a private IP. An attacker registers a domain whose DNS answer changes: public IP during validation, 169.254.169.254 during the fetch. The server downloads the instance's IAM credentials and returns them as the "thumbnail". Checking at connect time, plus IMDSv2, closes both doors.

Common mistakes

  • Validating the URL string, or resolving once to check and again to fetch.
  • Following redirects without re-checking each hop.
  • Forgetting IPv6, IPv4-mapped IPv6, 0.0.0.0, and CGNAT space.
  • Letting the client use an environment HTTP proxy.
  • No size, time, or content-type limits on what you fetch.

Check yourself

  1. Why must the check happen in Dialer.Control rather than before the request?
  2. How does a redirect bypass a check on the initial URL?
  3. What does IMDSv2's hop limit of 1 defend against?

Key Takeaways

  • Check the IP you are about to connect to, every time, including redirects.
  • Block loopback, private, link-local, unspecified, CGNAT, and multicast, and restrict ports.
  • Layer cloud defences (IMDSv2, egress firewall rules) on top of the application check.

Across the series: Chapter 12 (web attack classes); TCP/IP guide, Chapter 53 (egress firewalling as a second layer); Go guide, Chapter 47 (secure coding in Go).

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36