Skip to content
gohacks.dev
APIs

SSRF in Go Services and Building the Safe HTTP Client

Stop server-side request forgery in Go by checking the real destination in the dialer, refusing private ranges and redirects, and capping the response.

11 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Any feature that makes your server fetch a URL a user supplied is a potential server-side request forgery. Link previews, webhook delivery, image import by URL, avatar from a URL, "check this endpoint", PDF generation from a web page, an XML parser that follows external entities. The user gives you a string, your server turns it into a network request, and the request comes from inside your network.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Inside is the whole problem. From there a request can reach things that the outside cannot: an admin panel on localhost, a database's HTTP interface, a Kubernetes API, an internal service that trusts anything on the private network, and above all the cloud provider's metadata endpoint at 169.254.169.254, which hands out temporary credentials to whoever asks from the machine. Many real breaches have started with an SSRF that fetched those credentials.

This article builds a safe HTTP client for such features in Go and tests it against the usual bypasses. It is the network twin of input limits and mass assignment, and it feeds into the pre-launch checklist. The code uses only the standard library and ran on Go 1.25.6.

Why checking the URL is not enough

The tempting defence is to parse the URL, look at the host, and reject localhost and 127.0.0.1. It fails in many ways:

  • Other spellings. http://2130706433/ is 127.0.0.1 in decimal, http://0x7f.1/ and http://017700000001/ are hex and octal forms, http://[::ffff:127.0.0.1]/ is the IPv4-mapped IPv6 form, http://[::1]/ is IPv6 loopback, and http://localhost. has a trailing dot.
  • Names that resolve to private addresses. An attacker owns evil.example and points its DNS record at 10.0.0.5 or 127.0.0.1. The hostname looks innocent.
  • Redirects. The first URL is an innocent public page that answers 302 Location: http://169.254.169.254/.... Your client follows it.
  • DNS rebinding. The attacker's DNS server answers with a public address when your code checks and a private one when your client connects, a moment later, with a very short TTL.

All of these have the same cure: do not decide from the URL or from an earlier lookup. Decide from the address the socket is about to connect to.

The check at connection time

Go's net.Dialer has a Control function. It is called after the hostname has been resolved and before the connection is made, with the exact ip:port string that will be used. Rejecting there covers every path: direct URLs, redirects (each new connection goes through the dialer again), alternative spellings (they are normalised to an IP by then), and rebinding (the check sees the same address the socket uses).

safeclient.goGo
package main

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

var blocked = []netip.Prefix{
	netip.MustParsePrefix("0.0.0.0/8"),
	netip.MustParsePrefix("10.0.0.0/8"),
	netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
	netip.MustParsePrefix("127.0.0.0/8"),
	netip.MustParsePrefix("169.254.0.0/16"), // link-local, includes the cloud metadata address 169.254.169.254
	netip.MustParsePrefix("172.16.0.0/12"),
	netip.MustParsePrefix("192.168.0.0/16"),
	netip.MustParsePrefix("::1/128"),
	netip.MustParsePrefix("fc00::/7"),
	netip.MustParsePrefix("fe80::/10"),
}

var ErrBlocked = errors.New("destination address is not allowed")

// IsBlocked reports whether an address is private, loopback, link-local or otherwise internal.
func IsBlocked(ip netip.Addr) bool {
	ip = ip.Unmap() // ::ffff:127.0.0.1 is loopback in disguise
	if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
		return true
	}
	for _, p := range blocked {
		if p.Contains(ip) {
			return true
		}
	}
	return false
}

// control runs after DNS resolution, just before the socket connects, with the address that will really be used.
// Checking here, and not on the hostname, is what defeats DNS rebinding.
func control(allow func(addr string) bool) func(network, address string, c syscall.RawConn) error {
	return func(network, address string, _ syscall.RawConn) error {
		ap, err := netip.ParseAddrPort(address)
		if err != nil {
			return err
		}
		if allow != nil && allow(address) {
			return nil
		}
		if IsBlocked(ap.Addr()) {
			return fmt.Errorf("%w: %s", ErrBlocked, ap.Addr())
		}
		return nil
	}
}

// New returns a client for fetching URLs that users supply. allow is a hook for tests; it is nil in production.
func New(allow func(addr string) bool) *http.Client {
	dialer := &net.Dialer{Timeout: 3 * time.Second, Control: control(allow)}
	return &http.Client{
		Timeout: 5 * time.Second,
		Transport: &http.Transport{
			DialContext:         dialer.DialContext,
			Proxy:               nil, // an environment proxy would let a request reach internal hosts through it
			TLSHandshakeTimeout: 3 * time.Second,
			MaxIdleConns:        10,
			DisableKeepAlives:   true,
		},
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) >= 3 {
				return errors.New("too many redirects")
			}
			if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
				return fmt.Errorf("redirect to scheme %q refused", req.URL.Scheme)
			}
			return nil // the next dial is checked by control, so a redirect to an internal address fails there
		},
	}
}

// Fetch validates the URL, applies the client and caps the body.
func Fetch(ctx context.Context, c *http.Client, rawURL string, max int64) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
	if err != nil {
		return "", err
	}
	if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
		return "", fmt.Errorf("scheme %q is not allowed", req.URL.Scheme)
	}
	if req.URL.User != nil {
		return "", errors.New("credentials in the URL are not allowed")
	}
	resp, err := c.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	b, err := io.ReadAll(io.LimitReader(resp.Body, max+1))
	if err != nil {
		return "", err
	}
	if int64(len(b)) > max {
		return "", errors.New("response too large")
	}
	return string(b), nil
}

Walk through the parts.

IsBlocked treats an address as internal if it is loopback, private (RFC 1918 and IPv6 unique local), link-local, multicast or unspecified. It first calls Unmap, because an IPv4 address written as IPv4-mapped IPv6 (::ffff:127.0.0.1) would otherwise slip past an IPv4-only check. The prefix list adds ranges the standard predicates do not cover, such as the carrier-grade NAT block 100.64.0.0/10.

control parses the address the dialer is about to use and returns an error for blocked ones. The test hook allow exists only so the lab can permit one loopback server that stands in for a public site; production code passes nil.

New builds an http.Client that uses that dialer, and sets Proxy: nil. That matters: a transport with the default proxy-from-environment behaviour would send the request to the proxy, and the proxy would connect to the internal address on your behalf, outside your check. It also sets timeouts, disables keep-alives (so a reused connection cannot sneak past the dial check to a different destination), and caps redirects.

Fetch validates the scheme and refuses credentials in the URL, runs the request and reads at most max bytes, failing if the body is larger. An attacker's endpoint that streams forever would otherwise fill your memory.

Testing the bypasses

The program starts two servers on loopback, an "internal" one and a "public" one, and allows only the public one by address for the test. Then it tries the attacks.

main.goGo
package main

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

func short(err error) string {
	s := err.Error()
	if i := strings.Index(s, ": destination"); i >= 0 {
		return s[i+2:]
	}
	if len(s) > 90 {
		return s[:90] + "..."
	}
	return s
}

func main() {
	// "internal" stands for an admin panel on the machine; "public" stands for a site the user is allowed to point us at.
	internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "INTERNAL ADMIN PANEL") }))
	defer internal.Close()
	public := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/redirect" {
			http.Redirect(w, r, internal.URL, http.StatusFound)
			return
		}
		fmt.Fprint(w, "hello from a public site")
	}))
	defer public.Close()

	// Both test servers listen on loopback, so the test allows exactly the "public" one by address.
	publicAddr := strings.TrimPrefix(public.URL, "http://")
	safe := New(func(addr string) bool { return addr == publicAddr })
	ctx := context.Background()

	try := func(label, url string) {
		body, err := Fetch(ctx, safe, url, 1<<16)
		if err != nil {
			fmt.Printf("%-36s refused: %s\n", label, short(err))
			return
		}
		fmt.Printf("%-36s ok: %s\n", label, body)
	}
	try("public site", public.URL)
	try("internal server directly", internal.URL)
	try("loopback by name", "http://localhost:1/")
	try("metadata address", "http://169.254.169.254/latest/meta-data/")
	try("decimal form of 127.0.0.1", "http://2130706433:1/")
	try("IPv4-mapped IPv6 loopback", "http://[::ffff:127.0.0.1]:1/")
	try("IPv6 loopback", "http://[::1]:1/")
	try("private range", "http://10.0.0.5/")
	try("file scheme", "file:///etc/passwd")
	try("public redirects to internal", public.URL+"/redirect")

	// DNS rebinding against a check that looks at the name first. The resolver answers differently each time.
	calls := 0
	resolve := func(host string) netip.Addr {
		calls++
		if calls == 1 {
			return netip.MustParseAddr("93.184.216.34") // the check sees a public address
		}
		return netip.MustParseAddr("127.0.0.1") // the connection gets the real answer
	}
	naive := func(host string) error {
		if IsBlocked(resolve(host)) {
			return ErrBlocked
		}
		return nil
	}
	fmt.Println("rebinding, check by hostname: ", naive("attacker.example.test") == nil, "(allowed), then the dial resolves again and gets", resolve("attacker.example.test"))
	d := &net.Dialer{Timeout: time.Second, Control: control(nil)}
	_, err := d.DialContext(ctx, "tcp", internal.Listener.Addr().String())
	fmt.Println("rebinding, check at connect:   ", short(err))
}
Text
public site                          ok: hello from a public site
internal server directly             refused: destination address is not allowed: 127.0.0.1
loopback by name                     refused: destination address is not allowed: ::1
metadata address                     refused: destination address is not allowed: 169.254.169.254
decimal form of 127.0.0.1            refused: destination address is not allowed: 127.0.0.1
IPv4-mapped IPv6 loopback            refused: destination address is not allowed: 127.0.0.1
IPv6 loopback                        refused: destination address is not allowed: ::1
private range                        refused: destination address is not allowed: 10.0.0.5
file scheme                          refused: scheme "file" is not allowed
public redirects to internal         refused: destination address is not allowed: 127.0.0.1
rebinding, check by hostname:  true (allowed), then the dial resolves again and gets 127.0.0.1
rebinding, check at connect:    destination address is not allowed: 127.0.0.1

The first line shows the legitimate fetch working. Everything after it is refused, and mostly with the same message and the address that was resolved:

  • The internal server, requested directly, is refused: 127.0.0.1.
  • localhost resolves first to ::1 on this machine, and that is refused.
  • The metadata address 169.254.169.254 is refused before a packet is sent. This is the single most valuable test in the list.
  • 2130706433, the decimal spelling, is normalised by the resolver to 127.0.0.1, and refused with that address in the message. The attacker's trick has no effect because the check sees what the socket sees.
  • The IPv4-mapped IPv6 form is refused because IsBlocked unmaps it first.
  • ::1 and the private range are refused.
  • file:///etc/passwd is refused by the scheme check, never reaching the network.
  • The redirect case is the interesting one. The public site is fetched, answers 302 to the internal server, and the client follows it, then the second dial is refused. The check ran twice because there were two connections, which is exactly the property that URL validation lacks.

The last two lines model DNS rebinding. A fake resolver returns a public address the first time and 127.0.0.1 the second. A check that resolves the hostname to approve it sees the public address and says yes, and then the connection resolves again and goes to loopback. The connect-time check, run against the real destination, refuses. (The resolver here is simulated, not a real attacker's DNS server, but it models the timing that makes the attack work.)

What the safe client does not cover

Allow-lists beat block-lists. If the feature only needs to fetch from a known set of hosts (a specific partner API, image CDNs you control), validate the host against that set as well. The address check is the safety net, not the policy.

Other egress paths. An SSRF-prone library other than net/http, a headless browser rendering a user URL, a PDF renderer, an XML parser resolving entities, a Git or image tool that takes a URL. Each needs the same treatment. Browsers in particular follow JavaScript redirects and load subresources, so isolate them on a network that cannot reach internal services.

Network-level controls. The strongest defence is that the machine cannot reach what it should not: run URL-fetching workers in a subnet with egress rules that allow only the public internet, and block the metadata endpoint with a host firewall rule or, on AWS, require IMDSv2 (which needs a header and a PUT that most SSRF primitives cannot send) and set the hop limit to 1. Application checks and network rules are complementary; either alone has been bypassed.

Responses can still hurt. A fetched page is untrusted. Do not render it as HTML in your own origin, do not follow Content-Type blindly, and cap time and size as Fetch does. If you return the fetched body to the user, SSRF turns into a data-reading tool for any internal service that the check allows.

Blind SSRF. If the user does not see the response, an attacker can still use timing and error differences to scan your network. Return the same generic error for every failure of a user-supplied URL.

Non-HTTP schemes. Redirects to gopher:// or ftp:// have been used to talk to internal services. The CheckRedirect function refuses anything but http and https.

Testing and operating it

Keep the table of attacks in the repository as a test, including the metadata address and the encoded forms, and run it with -race. Add a unit test for IsBlocked over a table of addresses at the edges of each range (for example 172.15.255.255 allowed, 172.16.0.0 blocked, 172.32.0.0 allowed). Log every refusal with the requesting account and the URL, because a burst of refused private addresses from one user is an attack in progress.

Rate limit the feature, and require authentication. An open URL fetcher is a free proxy and a port scanner for everyone. Finally, document who may use it and what it may reach, so the next developer does not add a "fetch from internal URL" mode for convenience.

The OWASP SSRF prevention cheat sheet lists these controls by scenario, and the standard library's net.Dialer.Control documentation describes the hook this client depends on. The same dialer idea applies to the reverse proxy in a gateway, which also forwards requests to addresses it was told about.

Taggedhardening
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.