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.

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/is127.0.0.1in decimal,http://0x7f.1/andhttp://017700000001/are hex and octal forms,http://[::ffff:127.0.0.1]/is the IPv4-mapped IPv6 form,http://[::1]/is IPv6 loopback, andhttp://localhost.has a trailing dot. - Names that resolve to private addresses. An attacker owns
evil.exampleand points its DNS record at10.0.0.5or127.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).
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.
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))
}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.1The 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.
localhostresolves first to::1on this machine, and that is refused.- The metadata address
169.254.169.254is 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 to127.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
IsBlockedunmaps it first. ::1and the private range are refused.file:///etc/passwdis 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.


