Server-Side Request Forgery is a web security vulnerability in which we attempt to trick the server to access resources that we, the client who did the request, would normally not be able to access. In practice this usually means trying to access other resources within the network the server is running in or other services on the same host.

This usually happens when there’s a way for an attacker to control the URL a server is going to access. This is fairly prevalent in federated protocols as we often need to go in and dereference resources (like media) but can happen in all kinds of scenarios if a URL can somehow be passed into the backend.

A very common target for this are the various metadata APIs that are prevalent in cloud environments and running on http://169.254.169.254. Some databases provide REST APIs that are also an interesting target, but it can also be used to explore a network and trying to access services running in private IP address space that would otherwise be unaccessible from the outside.

In most cases backends might filter out URL/requests directly containing an IP address, but when the attacker can control the domain they can also control the DNS resolution. So even though the request might be for https://example.net, if the attacker controls the DNS for example.net they can make that lookup return 10.0.0.1.

It’s also important to note that these protections should not be limited to IP addresses only. You should also check the port you’re connecting to. If you don’t ever expect to connect to something over UDP, it’s probably worth checking for that too.

Implementing SSRF protections thus usually comes down to:

  • Do the DNS resolution so you get the IP you will connect to
  • Check which protocols, ports and IPs are permitted or denied and limit the request accordingly

Which destinations to block

Knowing all of this, how do we then get the IP addresses we should block? A few immediately come to mind like the RFC 1918 Private Address ranges. You’ll already be familiar with them:

  • 10.x.x.x/8
  • 192.168.0.x/24
  • 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)

But, there’s a ton more special IP ranges that aren’t globally routable unicast addresses. 127.x.x.x/8 comes to mind for example, 198.51.100.x/24 which is one of the “for documentation purposes” ranges or multicast 224.0.0.0/4 (224.0.0.0 - 239.255.255.255). There’s more and then of course there’s IPv6. These special purpose networks aren’t static either, every now and then a new one gets defined and sometimes an existing one gets reused for something else.

Someone: But Daenney, surely nobody would actually use a reserved prefix like 198.18.0.0/15 for something other than what the RFC dictates? And surely the OS wouldn’t actually route packets anywhere for a prefix that has explicitly been qualified as an invalid destination or non-forwardable?

Me: Oh sweet summer child

It’s also very easy to forget certain special cases, like 0.x.x.x/8 or IPv6’s special destination ::/128. With these special cases, behaviour can also differ between the Linux kernel, the BSDs, Windows and macOS. So depending on which platform you run your backend there may or may not be an extra gotcha or two lurking in the dark.

Looking around at different SSRF modules in Go and other languages like Python and Ruby, we can see that what ends up on the block list seems to largely be based on what the maintainer managed to come up with. Over time other folks have contributed additional ranges so most implementations are reasonably complete, but often don’t cover everything and sometimes disagree between them. Many implementations are complete enough for IPv4 but are generally not correct for IPv6 and the special IPv4-mapped-IPv6 addresses are usually not handled correctly either.


This is an intermezzo about some of the special ranges. What’s stated in this section applies to the Linux kernel. The behaviour on the BSDs, macOS and Windows might be different. Feel free to skip this bit.

IPv4 has the special 0.0.0.0/32 IP meaning “this machine on this network” and IPv6 has the roughly equivalent ::/128 “unspecified address”. Because both of these addresses are defined as not valid in an IP packet for the destination and that they should not be forwarded, the kernel handles them specially. When we hand off a packet to the kernel to send with either of those destinations, the kernel substitutes them for localhost. Since they’re now destined for localhost, they get sent out on the loopback interface and never leave your machine. This makes them functionally equivalent to 127.0.0.1 and ::1 so you want to be sure to block them instead of letting them reach the kernel.

However, even though 0.0.0.0/8, the subnet that contains 0.0.0.0/32 is also defined as an invalid destination and not forwardable, it does not receive that same treatment. This means that a packet with destination 0.0.0.1 will actually leave your system. Depending on how the router at your edge is configured, this may make it all the way to your ISP until someone or something decides that “actually, there’s no route for this prefix so bye”. Since that’s fairly useless, we can block all of 0.0.0.0/8 to begin with, which also covers 0.0.0.0/32.

I’m not sure why this is different, but many TCP stacks have been around forever and have a lot of history in them. Even if we could techincally fix this, it might break something somewhere so folks might be hesitant to do so. Having too many of these checks in the kernel can also be problematic, since ranges can be repurposed over time and then we suddenly wouldn’t be able to connect to them without updating our kernel. This is not likely to happen for this particular range since that would break the world.


How can we do better

Thankfully the Internet Assigned Numbers Authority is here to save us. As with many things, IANA maintains lists (registries) of known “things”. These things are usually numbers, surprise, that in a certain contexts have special meaning. These numbers in turn usually come from IETF RFCs. There’s lists for well-known port numbers, TLS cipher suites, OIDs in use in SNMP and many other things.

For our purposes, IANA maintains the IPv4 and IPv6 Special Purpose Registries which contain most, yes most not all, IP ranges you probably don’t want to allow connections to. Aside from the prefixes in these registries we also don’t want to allow connections to IPv4 and IPv6 multicast addresses.

Some of the prefixes listed in the IANA registries also overlap, as they call out each subnet that has been assigned specific meaning. This means that in our case we also need to reduce this list down to the biggest prefix that encompasses them in order to limit the amount of prefixes we need to check.

Go

In Go, we can use the Control method on a net.Dialer to prevent a connection to a destination from being established. The Control method sits at the perfect point in time in the chain. DNS resolution has already taken place so we have an IP, but we haven’t connected to the destination yet.

Since we’re using the net.Dialer for this, the protections apply to any protocol using TCP or UDP. It’s not limited to HTTP.

Using the Control method this way is not what it was initially designed for. This came up in a discussion in the Go issue tracker, but no other hook currently exists for this purpose either. Every SSRF mitigation solution for Go thus ends up using it and there doesn’t really seem to be an issue with doing so in practice.

The SSRF package I’ve recently created does exactly that, but it differs from other implementations in that most prefixes on that list are generated directly from the IANA registries. A generator is also included that runs twice a month through GitHub Actions to keep these up to date. Running it twice a month is overkill because these things only change every couple of years, but there’s no harm in it. It also ensures we exercise this automation regularly which is important to ensure things keep working.

My package makes use of the new netip module in Go 1.18. This package makes life a bit easier and also correctly classifies IPv4-mapped-IPv6 addresses as IPv6, instead of the net package which sees them as IPv4. This works out great in our case because that prefix is contained in the IPv6 Special Purpose Registry, not the IPv4 one. One less special case to deal with.

It also provides ways to explicitly allow an otherwise blocked prefix, add additional blocked prefixes and control which ports and protocols can be used.

Conclusion

Implementing SSRF protections is an important aspect of keeping your services secure on the internet. Many packages exist in different languages to help with this but they all seem to have an artisanally curated list of prefixes. Many don’t implement the right protections for IPv6 and certain especially tricky ranges whose implications might vary across OS are also often forgotten about.

The SSRF package I’ve published for Go tries to avoid all these issues by using the IANA registries to create the denylists. A number of additional prefixes are added to complete the set and the package is kept up to date by regenerating the prefixes from the registries on a regular cadence.