Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix SSRF in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Attach the policy to the connection, not the URL string. Give http.Transport a DialContext that resolves the host, rejects loopback, private, link-local and other non-public addresses with net/netip, and dials the vetted IP. Check scheme and userinfo in CheckRedirect on every hop, leave Proxy nil, set a timeout and cap the body with io.LimitReader.

Stack
  • Language Go
  • Runtime Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
  • Framework Go standard library 1.27.2 (net/http client, net/netip)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 26 KB
On this page

Steps

  1. Find every server-side fetch built from request data: http.Get, http.Post, client.Do and http.NewRequestWithContext with a URL from r.URL.Query(), a form or a JSON body, in webhooks, link previews, image imports and OAuth or OIDC metadata fetches.
  2. Parse the URL once with url.Parse, allow only http and https, and reject userinfo (u.User != nil).
  3. Build one http.Client whose Transport has a DialContext that splits the address, allows only ports 80 and 443, resolves the host (or reads an IP literal, and refuses a host that ends in a number), rejects the whole answer if any address is not public, and dials a vetted address with netip.AddrPortFrom.
  4. Classify each address with net/netip: Unmap IPv4-mapped addresses, unwrap NAT64 and 6to4, reject !IsGlobalUnicast() and IsPrivate(), then the special-purpose ranges those predicates accept, and any IPv6 address outside 2000::/3.
  5. Set CheckRedirect to run the scheme and userinfo checks on each hop and to stop after a few; every hop dials through the same DialContext, so its address is vetted again. Leave Transport.Proxy nil.
  6. Set Client.Timeout, read the body through io.LimitReader with a byte cap, answer a generic 400 or 502, and log the destination and reason with slog.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.goGo
// Fetch has no destination policy: the caller's URL goes straight to the client.
func (v *Vulnerable) Fetch(ctx context.Context, rawURL string) (transport.Summary, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
	if err != nil {
		return transport.Summary{}, err
	}
	res, err := v.client.Do(req)
	if err != nil {
		return transport.Summary{}, err
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body) // no size limit: read whatever the destination sends
	if err != nil {
		return transport.Summary{}, err
	}
	return transport.Summarise(res, body), nil
}

With IMPL=vulnerable, 12 of 15 tests fail (10 exploit tests).

The fix: vet every address in the dialer

Fixedimpl/fixed.goGo
var (
	// Special-purpose IPv4 ranges (IANA registry) that netip's IsGlobalUnicast accepts and
	// IsPrivate does not cover, and the IPv6 documentation range.
	nonPublic = []netip.Prefix{
		netip.MustParsePrefix("0.0.0.0/8"),       // "this network"
		netip.MustParsePrefix("100.64.0.0/10"),   // carrier-grade NAT
		netip.MustParsePrefix("192.0.0.0/24"),    // IETF protocol assignments
		netip.MustParsePrefix("192.0.2.0/24"),    // documentation
		netip.MustParsePrefix("192.88.99.0/24"),  // 6to4 relay anycast
		netip.MustParsePrefix("198.18.0.0/15"),   // benchmarking
		netip.MustParsePrefix("198.51.100.0/24"), // documentation
		netip.MustParsePrefix("203.0.113.0/24"),  // documentation
		netip.MustParsePrefix("240.0.0.0/4"),     // reserved
		netip.MustParsePrefix("2001:db8::/32"),   // documentation
	}
	globalIPv6 = netip.MustParsePrefix("2000::/3")
	nat64      = netip.MustParsePrefix("64:ff9b::/96")
	sixToFour  = netip.MustParsePrefix("2002::/16")
)

// publicAddr reports whether addr is a public unicast address. An IPv4 address embedded in
// IPv6 (IPv4-mapped, NAT64, 6to4) is judged as the IPv4 address it reaches.
func publicAddr(addr netip.Addr) bool {
	addr = addr.WithZone("").Unmap()
	if b := addr.As16(); nat64.Contains(addr) {
		addr = netip.AddrFrom4([4]byte(b[12:16]))
	} else if sixToFour.Contains(addr) {
		addr = netip.AddrFrom4([4]byte(b[2:6]))
	}
	if !addr.IsGlobalUnicast() || addr.IsPrivate() || (addr.Is6() && !globalIPv6.Contains(addr)) {
		return false // loopback, link-local (169.254.169.254), private, multicast, unspecified
	}
	for _, prefix := range nonPublic {
		if prefix.Contains(addr) {
			return false
		}
	}
	return true
}

// dial is the Transport's DialContext. It enforces the port allowlist, refuses the whole
// answer if any address is not public, and connects to a vetted address.
func (f *Fixed) dial(ctx context.Context, network, address string) (net.Conn, error) {
	host, portText, err := net.SplitHostPort(address)
	if err != nil {
		return nil, err
	}
	port, err := strconv.ParseUint(portText, 10, 16)
	if err != nil || (port != 80 && port != 443) {
		return nil, transport.Blocked("port not allowed")
	}
	addrs, err := f.resolve(ctx, host)
	if err != nil {
		return nil, err
	}
	for _, addr := range addrs {
		if !publicAddr(addr) {
			return nil, transport.Blocked("non-public address " + addr.String())
		}
	}
	return f.net.DialContext(ctx, network, netip.AddrPortFrom(addrs[0], uint16(port)))
}

// resolve reads an IP literal itself and asks the resolver only about names. A host that ends
// in a number is an IPv4 address in another notation (2130706433, 0177.0.0.1, 0x7f.0.0.1):
// netip reads only dotted decimal, while getaddrinfo reads these as 127.0.0.1, so refuse them.
func (f *Fixed) resolve(ctx context.Context, host string) ([]netip.Addr, error) {
	if addr, err := netip.ParseAddr(host); err == nil {
		return []netip.Addr{addr}, nil
	}
	if endsInNumber(host) {
		return nil, transport.Blocked("IPv4 address in a non-standard notation")
	}
	addrs, err := f.net.LookupNetIP(ctx, host)
	if err == nil && len(addrs) == 0 {
		err = transport.Blocked("host did not resolve")
	}
	return addrs, err
}

// endsInNumber is the URL Standard's test for an IPv4 host: the last label is all decimal
// digits, or 0x followed by hex digits.
func endsInNumber(host string) bool {
	labels := strings.Split(strings.TrimSuffix(host, "."), ".")
	last := strings.ToLower(labels[len(labels)-1])
	if hex, ok := strings.CutPrefix(last, "0x"); ok {
		return strings.Trim(hex, "0123456789abcdef") == ""
	}
	return last != "" && strings.Trim(last, "0123456789") == ""
}

With IMPL=fixed, 15 of 15 tests pass.

The fix: re-validate every redirect

Fixedimpl/fixed.goGo
// NewFixed builds the client once. Every hop dials through f.dial, CheckRedirect runs the URL
// policy on each redirect target (which the client has already resolved against the URL of
// the hop that returned it) and stops after MaxRedirects. Proxy stays nil: with a proxy the
// dialer would see only the proxy's address.
func NewFixed(n transport.Network) *Fixed {
	f := &Fixed{net: n}
	f.client = &http.Client{
		Transport: &http.Transport{DialContext: f.dial, Proxy: nil},
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) > transport.MaxRedirects {
				return transport.Blocked("too many redirects")
			}
			return checkURL(req.URL)
		},
		Timeout: transport.Timeout,
	}
	return f
}

// Fetch checks the first URL, fetches it and reads at most MaxResponseBytes.
func (f *Fixed) Fetch(ctx context.Context, rawURL string) (transport.Summary, error) {
	target, err := url.Parse(rawURL)
	if err != nil {
		return transport.Summary{}, transport.Blocked("invalid URL")
	}
	if err := checkURL(target); err != nil {
		return transport.Summary{}, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
	if err != nil {
		return transport.Summary{}, transport.Blocked("invalid URL")
	}
	res, err := f.client.Do(req)
	if err != nil {
		return transport.Summary{}, err // a *transport.BlockedError from dial or CheckRedirect, wrapped
	}
	defer res.Body.Close()
	body, err := io.ReadAll(io.LimitReader(res.Body, transport.MaxResponseBytes+1))
	if err != nil {
		return transport.Summary{}, err
	}
	if len(body) > transport.MaxResponseBytes {
		return transport.Summary{}, transport.Blocked("response exceeded size limit")
	}
	return transport.Summarise(res, body), nil
}

With IMPL=fixed, 15 of 15 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.goGo
// screen rejects other schemes and internal IP literals. Anything that does not parse as an
// address is taken to be a harmless host name.
func screen(rawURL string) error {
	u, err := url.Parse(rawURL)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
		return transport.Blocked("invalid URL or scheme")
	}
	if addr, err := netip.ParseAddr(u.Hostname()); err == nil {
		if addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() || addr.IsUnspecified() {
			return transport.Blocked("internal address")
		}
	}
	return nil
}

With IMPL=wrong, 8 of 15 tests fail (7 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testapp_test.goGo
// Exploit: each payload names an internal destination a different way. The secure outcome is
// the same every time: the API returns 400 and the internal service is never dialled.

func TestExploitLoopbackByIPv4Literal(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://127.0.0.1/latest/meta-data/")
}

func TestExploitLoopbackAsADecimalInteger(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://2130706433/")
}

func TestExploitLoopbackInOctal(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://0177.0.0.1/")
}

func TestExploitLoopbackInHex(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://0x7f.0.0.1/")
}

func TestExploitIPv6LoopbackLiteral(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://[::1]/")
}

func TestExploitIPv4MappedIPv6Loopback(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://[::ffff:127.0.0.1]/")
}

func TestExploitCloudMetadataAddressByHostname(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://metadata.internal.example/latest/meta-data/")
}

func TestExploitPrivateAddressByHostname(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://db.internal.example/")
}

func TestExploitAnInnocentLookingNameThatResolvesToLoopback(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://rebind.example/")
}

func TestExploitARedirectFromAnAllowedHostToAnInternalOne(t *testing.T) {
	newEnv(t).assertBlocked(t, "http://files.partner.example.test/redirect-internal")
}

Pitfalls

  • netip's predicates are not an access-control list, and its documentation says IsPrivate should not be used for access control. IsPrivate covers only 10/8, 172.16/12, 192.168/16 and fc00::/7; IsGlobalUnicast is true for 100.64.0.1, 0.1.2.3, 240.0.0.1, NAT64 64:ff9b::7f00:1 and 6to4 2002:7f00:1:: (checked on Go 1.27.2). Add the special-purpose ranges yourself.
  • netip.ParseAddr and net.ParseIP read only dotted decimal and reject leading zeros, so 2130706433, 0177.0.0.1 and 0x7f.0.0.1 are not addresses to them. Go's own resolver then looks them up as names and fails, but the cgo resolver, which Go uses on macOS and when resolv.conf or nsswitch.conf need it, calls getaddrinfo and gets 127.0.0.1 (checked with GODEBUG=netdns=go and netdns=cgo). The fix refuses a host that ends in a number, as the URL Standard does.
  • Checking the URL and then calling http.Get(url) resolves the name twice, and the second answer can be different (DNS rebinding). Check in the dialer that opens the connection, and connect to the address you checked.
  • The default client follows up to 10 redirects to any host. CheckRedirect sees each target URL but not its address, so the address check belongs in the dialer, which every hop goes through.
  • http.DefaultTransport sends requests through the proxy named in HTTP_PROXY or HTTPS_PROXY; then the dialer connects to the proxy and never sees the destination. Use a Transport with Proxy nil, or enforce the policy at the proxy. DialTLSContext, if set, replaces DialContext for https requests and needs the same check.
  • If you would rather let net.Dialer resolve, put the address check in Dialer.ControlContext, which runs for each resolved address just before the connect; the dialer then never connects to an address the check refused.
  • A permitted host can still stream forever or hang: cap the bytes with io.LimitReader and set Client.Timeout.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs ssrf/go-nethttp does this for you, in Docker if you do not have Go).
  2. Each exploit asserts the request is refused with 400 and the internal test server records zero connection attempts, for 127.0.0.1, decimal, octal and hex, [::1], [::ffff:127.0.0.1], names resolving internal, and a redirect to an internal host.
  3. Run CodeQL's go/request-forgery and review every result, including those in code that already has a policy; gosec and Semgrep CE found nothing here because the handler and the fetch are in different packages.
  4. Against your own endpoint, request http://169.254.169.254/latest/meta-data/ and http://2130706433/ and expect refusals, and confirm that a 302 to an internal host is not followed.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix SSRF in Go and net/http
ToolRuleWhat it found here
Semgrepgo.lang.security.injection.tainted-url-host.tainted-url-hostNo findings, and p/golang reported nothing either. The rule is marked interfile, so Semgrep CE does not follow the URL from the handler in app.go into impl/, and its sinks are http.Get, Head, Post and PostForm, a client's Get, Head, Post and PostForm, and http.NewRequest, not NewRequestWithContext with client.Do. On a one-file probe it flagged http.Get(r.URL.Query().Get("url")).Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> .
CodeQLgo/request-forgeryThree results, one per implementation: the request in impl/vulnerable.go, impl/wrong.go and impl/fixed.go, each traced from r.URL in app.go through the Fetcher interface. The fixed one is a false positive: the policy lives in the Transport's DialContext and CheckRedirect, which the query cannot see. Treat the query as a list of every outbound request built from input, and review each.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165)
gosecG704SSRF via taint analysisNo findings: the taint analysis does not follow the URL from the handler through the impl.Fetcher interface. On the one-file probe it flagged both http.Get(target) and http.DefaultClient.Do(req) built from r.URL.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./...
gosecG107URL provided to HTTP request as taint inputNo findings: it is an AST rule for http.Get and friends with a variable URL, and the card builds requests with http.NewRequestWithContext. On the probe it flagged http.Get(target). gosec ran every default rule on the card, including G119 (redirect policy), and reported nothing.Checked: gosec 2.29.0

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http client, net/netip)
net/http, net/netip, net/url (standard library)Go 1.27.2
gotest.tools/gotestsum (JUnit report, a go.mod tool)1.13.0
golang.org/x/text (raised for gotestsum)0.42.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go mod download && go build ./... tool'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=fixed \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=vulnerable \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=wrong \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs ssrf/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 26 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
Toolchain
  • go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
  • gotestsum 1.13.0
  • semgrep 1.180.0
  • gosec 2.29.0
  • codeql CLI 2.27.1, codeql/go-queries 1.6.12
  • govulncheck 1.8.0
Matrix
fixed: 15 of 15 tests pass; vulnerable: 12 of 15 tests fail (10 exploit tests); wrong: 8 of 15 tests fail (7 exploit tests)
Test results
fixed 15/15 pass; vulnerable 12/15 fail (10 exploit tests); wrong 8/15 fail (7 exploit tests), with --network=none after install; the same on Go 1.26.9
Dependency freshness
No third-party module at run time: the fetcher uses net/http, net/netip and net/url. gotestsum 1.13.0 (2025-09-11) runs the tests; it asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds, so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release. govulncheck 1.8.0 found no vulnerabilities in the module or the standard library.
Notes
Same scenario, payloads and test names as the other stacks: a fake resolver modelled on getaddrinfo and a fake dialer that counts connections to the internal server. The wrong implementation is the check Go code tends to ship, netip.ParseAddr on the URL's host with IsLoopback, IsPrivate, IsLinkLocalUnicast and IsUnspecified: it blocks 127.0.0.1, [::1] and [::ffff:127.0.0.1] (netip's predicates see through IPv4-mapped addresses) and checks the scheme, but misses the three shorthand notations, the three names and the redirect, and has no size cap.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Server-Side Request Forgery Prevention Cheat Sheet
  2. Go standard library: net/http Client (CheckRedirect)
  3. Go standard library: net/http Transport (DialContext, Proxy)
  4. Go standard library: net/netip Addr (IsPrivate, IsGlobalUnicast, Unmap)
  5. Go standard library: net, name resolution (pure Go and cgo resolvers)
  6. Go standard library: net.Dialer (ControlContext)
  7. URL Standard: ends in a number checker
  8. IANA IPv4 Special-Purpose Address Registry
  9. IANA IPv6 Special-Purpose Address Registry
  10. RFC 6052: IPv6 Addressing of IPv4/IPv6 Translators (64:ff9b::/96)
  11. gosec 2.29.0 rules (G107, G704)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.