Skip to Content [alt-c]

December 20, 2019

Preventing Server Side Request Forgery in Golang

If your application makes requests to URLs provided by untrusted sources (such as users), you must take care to avoid server side request forgery (SSRF) attacks. Otherwise, an attacker might be able to induce your application to make a request to a service on your server's localhost or internal network. Since the service thinks the request is coming from a trusted source, it might perform a privileged action or return sensitive data that gets relayed by your application back to the attacker. This is particularly a problem when running in EC2, which exposes sensitive credentials over its metadata service, which is accessible over HTTP at a private IP address. SSRF attacks can be serious; one was exploited earlier this year to steal more than 100 million credit applications from Capital One.

One way to prevent SSRF attacks is to validate all addresses before connecting to them. However, you must do the validation at a very low layer to be effective. It's not sufficient to simply block URLs that contain "localhost" or an internal IP address, since an attacker could publish a DNS record under a public domain that resolves to an internal IP address. It's also insufficient to do the DNS lookup yourself and block a URL if the hostname resolves to an unsafe address; an attacker could set up a special DNS server that returns a safe address the first time it's queried, and the target address the second time when your application actually connects to the URL.

Instead, you need to hook deep into your HTTP client's networking stack and check for a safe address right before the HTTP client tries to access it.

Fortunately, Go makes it easy to hook in at just the right place, thanks to the Control field of net.Dialer, introduced in Go 1.11:

// If Control is not nil, it is called after creating the network
// connection but before actually dialing.
//
// Network and address parameters passed to Control method are not
// necessarily the ones passed to Dial. For example, passing "tcp" to Dial
// will cause the Control function to be called with "tcp4" or "tcp6".
Control func(network, address string, c syscall.RawConn) error // Go 1.11

This function is called by Go's standard library after the address has been resolved, but before connecting. The network argument is tcp4, udp4, tcp6, or udp6, and the address argument is an IP address and port number separated by a colon (e.g. 192.0.2.0:80 or [2001:db8:f942::3ab2]:443; split it with net.SplitHostPort, not strings.Split, to avoid IPv6 breakage). If the control function returns an error, the dial is aborted.

Here's an example control function that returns an error if the address is not safe. It's quite conservative, permitting only TCP connections to port 80 and 443 on public IP addresses (see here for the implementation of isPublicIPAddress). You may want to customize the control function to suit your application's needs.

func safeSocketControl(network string, address string, conn syscall.RawConn) error {
	if !(network == "tcp4" || network == "tcp6") {
		return fmt.Errorf("%s is not a safe network type", network)
	}

	host, port, err := net.SplitHostPort(address)
	if err != nil {
		return fmt.Errorf("%s is not a valid host/port pair: %s", address, err)
	}

	ipaddress := net.ParseIP(host)
	if ipaddress == nil {
		return fmt.Errorf("%s is not a valid IP address", host)
	}

	if !isPublicIPAddress(ipaddress) {
		return fmt.Errorf("%s is not a public IP address", ipaddress)
	}

	if !(port == "80" || port == "443") {
		return fmt.Errorf("%s is not a safe port number", port)
	}

	return nil
}

Once you have a control function, you can use it to make HTTP requests as follows (the various numbers below match those used by http.DefaultClient):

safeDialer := &net.Dialer{
	Timeout:   30 * time.Second,
	KeepAlive: 30 * time.Second,
	DualStack: true,
	Control:   safeSocketControl,
}

safeTransport := &http.Transport{
	Proxy:                 http.ProxyFromEnvironment,
	DialContext:           safeDialer.DialContext,
	ForceAttemptHTTP2:     true,
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

safeClient := &http.Client{
	Transport: safeTransport,
}

resp, err := safeClient.Get(untrustedURL)

The above code examples are in the public domain.

Comments

Reader Arkadiy Tetelman on 2019-12-21 at 19:21:

I was googling how to prevent SSRF in Golang and found this article published only yesterday - very serendipitous for me!

Your IPv6 blocking is missing a few other reserved addresses though - in particular it's missing the ipv4-mapped and ipv4-compatible ipv6 addresses. For instance I could make a request to the ipv6 address ::ffff:169.254.169.254 and access your cloud provider metadata service.

If it's helpful here's the ipv6 denylist I use in my SSRF ruby gem: https://github.com/arkadiyt/ssrf_filter/blob/master/lib/ssrf_filter/ssrf_filter.rb#L45-L65

In any case this post / the network hooking was extremely helpful for me - thank you!

Reply

Andrew Ayer on 2020-01-05 at 23:40:

Hi Arkadiy - I'm glad my post was useful!

You wrote:

For instance I could make a request to the ipv6 address ::ffff:169.254.169.254 and access your cloud provider metadata service.

I just tried this and my code properly rejects ::ffff:169.254.169.254: https://play.golang.org/p/If7PWPTrtU1

My code works properly because 169.254.0.0/16 is listed in reservedIPv4Nets, and Go's IPNet.Contains considers IPv4 and IPv4-mapped IPv6 addresses to be equivalent. Therefore, there is no need to separately enumerate reserved IPv4 addresses in mapped IPv6 form.

Reply

Reader Taras on 2022-03-21 at 19:49:

Thanks for the solution!

By the way:

// If Control is not nil, it is called -> after creating the network <- // connection but before actually dialing.

but in your post you write that:

This function is called by Go's standard library after the address has been resolved, but -> before connecting<- .

What is the true? :)

Reply

Andrew Ayer on 2022-03-24 at 20:25:

My post is correct. This doesn't mean the Go documentation is wrong, but it is confusing - "network connection" means the socket, not an active TCP connection. The socket starts out in an unconnected state, then Control is called, and then it dials, turning it into an active TCP connection.

Reply

Reader Jonas Faber on 2024-02-02 at 14:29:

Looking at smokescreen src code i wondered if the is isPublicIPAddress you linked to be safely replaced by by a some std lib method calls. e.g. https://go.dev/play/p/oz2CNqT-2Sr

smokescreen reference: https://github.com/stripe/smokescreen/blob/8c0fa26edf63f35d5632ba7682d78ff07a306819/pkg/smokescreen/smokescreen.go#L168

I will validate it against your shared isPublicIPAddress next, but figured it may be worth to share it here.

Reply

Post a Comment

Your comment will be public. To contact me privately, email me. Please keep your comment polite, on-topic, and comprehensible. Your comment may be held for moderation before being published.

(Optional; will be published)

(Optional; will not be published)

(Optional; will be published)

  • Blank lines separate paragraphs.
  • Lines starting with > are indented as block quotes.
  • Lines starting with two spaces are reproduced verbatim (good for code).
  • Text surrounded by *asterisks* is italicized.
  • Text surrounded by `back ticks` is monospaced.
  • URLs are turned into links.
  • Use the Preview button to check your formatting.