Info
You can test this code by visiting https://challenge.003random.com/ssrf-protection?value=https://localhost:1337. The server running there executes identical code to what’s below, except it includes an additional HTTP handler. This handler uses the value URL parameter as the destination for an HTTP request, which in turn utilizes the custom DialContext. Output and errors are returned to the user for visibility.

Introduction Link to heading

Four years ago, I published an initial solution to this problem in a gist, which you can find here. Despite the years that have passed and the flurry of subsequent publications, including blog posts and libraries, many still suffer from either security holes or unnecessary complexity. This post builds upon my earlier work, offering an optimized, yet simple, solution specifically tailored for Go, which defeats attack types such as DNS-rebinding or parsing issues.

Why Traditional Methods Fall Short in Go Link to heading

Conventional ways of mitigating SSRF include URL validation, checking the DNS-records before sending a HTTP request to the host, and so on. However, DNS-rebinding or URL parsing tricks can potentially bypass these measures, making a stronger safeguard essential.

The Deny-List Solution Link to heading

The deny-list approach filters out outbound HTTP requests to specific IP address ranges that are not needed for your application’s functionality. It does this by overriding Go’s native DialContext method within the HTTP Transport. Before establishing any connection, the target host DNS is resolved, and each IP is compared against a list of IP ranges that should be denied. This same, safe, IP is then used to create the connection with. Eliminating a Time-of-Check to Time-of-Use (TOCTTOU) attack, also known as DNS-rebinding.

Additional to the hardcoded list of private IP ranges, you can use the DenyPrivateRanges property, uses the built in Go IP.IsPrivate() method. However, this method only restricts based on RFC 1918 (IPv4 addresses) and RFC 4193 (IPv6 addresses), which does not include certain ranges, such as loopback IPs.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
	"time"
)

// DenyList offers way of doing SSRF-safe HTTP requests by creating a custom DialContext function based on a deny-list configuration
type Denylist struct {
	Values            []*net.IPNet // A custom list of IP ranges to block.
	DenyPrivateRanges bool         // Whether or not to block private IP ranges. This only covers RFC 1918 (IPv4 addresses) and RFC 4193 (IPv6 addresses).
}

var (
	dl = Denylist{
		DenyPrivateRanges: true,

		Values: []*net.IPNet{
			{IP: net.ParseIP("192.0.2.0"), Mask: net.CIDRMask(24, 32)},       // IPv4 Assigned as TEST-NET-1, documentation and examples
			{IP: net.ParseIP("192.88.99.0"), Mask: net.CIDRMask(24, 32)},     // IPv4 Reserved. Formerly used for IPv6 to IPv4 relay (included IPv6 address block 2002::/16)
			{IP: net.ParseIP("192.168.0.0"), Mask: net.CIDRMask(16, 32)},     // IPv4 Used for local communications within a private network
			{IP: net.ParseIP("203.0.113.0"), Mask: net.CIDRMask(24, 32)},     // IPv4 Assigned as TEST-NET-3
			{IP: net.ParseIP("198.51.100.0"), Mask: net.CIDRMask(24, 32)},    // IPv4 Assigned as TEST-NET-2, documentation and examples
			{IP: net.ParseIP("198.18.0.0"), Mask: net.CIDRMask(15, 32)},      // IPv4 Used for benchmark testing of inter-network communications between two separate subnets
			{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},      // IPv4 Shared address space for communications between a service provider and its subscribers when using a carrier-grade NAT.
			{IP: net.ParseIP("255.255.255.255"), Mask: net.CIDRMask(32, 32)}, // IPv4 Reserved for the "limited broadcast" destination address
			{IP: net.ParseIP("192.0.0.0"), Mask: net.CIDRMask(24, 32)},       // IPv4 IETF Protocol Assignments
			{IP: net.ParseIP("172.16.0.0"), Mask: net.CIDRMask(12, 32)},      // IPv4 Used for local communications within a private network
			{IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(8, 32)},         // IPv4 Used for local communications within a private network
			{IP: net.ParseIP("127.0.0.0"), Mask: net.CIDRMask(8, 32)},        // IPv4 Used for loopback addresses to the local host
			{IP: net.ParseIP("169.254.0.0"), Mask: net.CIDRMask(16, 32)},     // IPv4 Used for link-local addresses between two hosts on a single link when no IP address is otherwise specified
			{IP: net.ParseIP("224.0.0.0"), Mask: net.CIDRMask(4, 32)},        // IPv4 In use for IP multicast (Former Class D network)
			{IP: net.ParseIP("240.0.0.0"), Mask: net.CIDRMask(4, 32)},        // IPv4 Reserved for future use
			{IP: net.ParseIP("0.0.0.0"), Mask: net.CIDRMask(32, 32)},         // IPv4 unspecified address

			{IP: net.ParseIP("::1"), Mask: net.CIDRMask(128, 128)},   // IPv6 Loopback
			{IP: net.ParseIP("fc00::"), Mask: net.CIDRMask(7, 128)},  // IPv6 ULA
			{IP: net.ParseIP("fe80::"), Mask: net.CIDRMask(10, 128)}, // IPv6 Link-Local
			{IP: net.ParseIP("ff00::"), Mask: net.CIDRMask(8, 128)},  // IPv6 Multicast
			{IP: net.ParseIP("::"), Mask: net.CIDRMask(128, 128)},    // IPv6 unspecified address
		},
	}
)

func main() {
	client := &http.Client{
		Transport: &http.Transport{
			DialContext: dl.DialContext(),
		},
		Timeout: time.Duration(5 * time.Second),
	}

	req, err := http.NewRequest("GET", fmt.Sprintf("%s", os.Args[1]), nil)
	if err != nil {
		log.Fatal(err)
	}

	_, err = client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
}

// Allowed returns whether or not the given IP is allowed by the configuration specified.
func (dl Denylist) Allowed(IP net.IP) bool {
	if dl.DenyPrivateRanges && IP.IsPrivate() {
		return false
	}

	for _, r := range dl.Values {
		if r.Contains(IP) {
			return false
		}
	}

	return true
}

// DialContext returns a custom deny-list based DialContext, which blocks based on the configuration specified.
func (dl Denylist) DialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
	return func(_ context.Context, network string, addr string) (conn net.Conn, err error) {
		host, port, err := net.SplitHostPort(addr)
		if err != nil {
			return nil, err
		}

		IPs, err := net.LookupHost(host)
		if err != nil {
			return nil, err
		}

		for _, v := range IPs {
			IP := net.ParseIP(v)

			if !dl.Allowed(IP) {
				err = errors.New("IP not allowed")
				return
			}

			conn, err = net.Dial(network, net.JoinHostPort(v, port))
			if err == nil {
				break
			}
		}

		return
	}
}

Shout-out to EdOverflow for making the IPv6 list more comprehensive.

In Action Link to heading

Running this script will yield the following responses:

003random:ssrf-deny-list/ $ go run main.go http://localhost
2023/10/01 23:05:44 Get "http://localhost": IP not allowed
exit status 1
003random:ssrf-deny-list/ $ go run main.go http://[::1]:1234
2023/10/01 23:05:55 Get "http://[::1]:1234": IP not allowed
exit status 1
003random:ssrf-deny-list/ $ go run main.go http://127.0.0.1/test
2023/10/01 23:06:10 Get "http://127.0.0.1/test": IP not allowed
exit status 1
003random:ssrf-deny-list/ $ go run main.go http://user:[email protected]:1234/test
2023/10/01 23:06:27 Get "http://user:***@127.0.0.1:1234/test": IP not allowed
exit status 1

Benefits Link to heading

  1. Focused Blocking: The deny-list approach blocks a targeted set of IP addresses at the lowest request level. This ensures that the IP, which is considered safe, will also be the IP that is requested.
  2. Straightforward Implementation: It’s relatively simple to implement in Go, with no third-party dependencies required.

Considerations Link to heading

  1. Allow-List: Blacklists are prone to bypasses. Consider using an allow-list instead, to only allow connections to known and safe IPs.

Stay tuned for the next post where we’ll explore the converse strategy — an allow-list approach for outbound request filtering in Go.