Protecting your .NET app from Server-Side Request Forgery (SSRF) vulnerabilities.

A couple of weeks ago a friend asked what Server-Side Request Forgery (SSRF) was, how worried they should be, and how to fix it, as GitHub Copilot had told them that their application was vulnerable. This sent me down a rabbit hole thinking about my own .NET Bluesky SDK and how to protect that. Only one NuGet package claimed to address SSRF, and it hadn't been updated in years.

So, what's the risk to your applications and what can you do about it?

What is SSRF?

In short: SSRF is what happens when an application makes server-side requests to a user-controlled URL, and an attacker abuses that to reach internal services the attacker can’t access directly.

Many applications can import data from a user-specified URI or send data to one. An attacker can provide a URI that causes the application to send requests somewhere unexpected inside your network—like localhost or an internal host such as 10.0.0.1. As applications often have a trust relationship with the networks they run in, SSRF lets an attacker “borrow” that trust.

For a real-world example, Capital One’s largest breach was driven by SSRF. It affected around 100 million cardholders in the US and another 6 million in Canada, and exposed data including Social Security numbers, Social Insurance Numbers, and bank account details. The SSRF weakness was used to access AWS S3 bucket listings and related credentials.

Cloud environments can make SSRF more damaging because most providers expose an instance metadata service on a well-known link-local address (for example http://169.254.169.254/). If an attacker can trigger SSRF, they may be able to query that metadata endpoint from the application server and read the responses—sometimes including credentials, role information, or storage configuration (as in Capital One’s case).

If you want examples of SSRF payloads (and many other attacks), swissky’s GitHub repository is a great starting point.

If your application accepts a URI as input—directly from a user, from a request parameter, or from any other untrusted source—and then makes a request to that URI, you need to think about SSRF.

Protecting .NET applications against SSRF

From the examples above, it should be clear why user-controlled URIs need validation.

The obvious mitigation is to validate the URI by resolving it to IP addresses and checking those addresses against known unsafe ranges. Normally, security guidance prefers an allow list over a block list—but with 4,294,967,296 IPv4 addresses and 340,282,366,920,938,000,000,000,000,000,000,000,000 IPv6 addresses, an allow list isn’t practical here. A list of unsafe ranges is much more manageable, and many of those ranges are defined in RFCs such as RFC 1918, RFC 3927, RFC 4291, RFC 6052, and others.

You also need to decide which URI schemes are acceptable. In many cases you’ll want to allow only HTTPS (and possibly WSS). There are plenty of other schemes—FTP, telnet, gopher, ms-teams, and more—so checking the scheme is usually simpler than checking every possible address a hostname might resolve to.

That’s where idunno.Security.Ssrf comes in—a NuGet package that performs both checks.

Start by checking whether the URI itself is acceptable (before you even resolve DNS). IsUnsafeUri validates that the URI is absolute, not a UNC path, not localhost, and that it represents either a DNS name or an IPv4/IPv6 address. It also verifies that the scheme is HTTPS or WSS.

if (idunno.Security.Ssrf.IsUnsafeUri(new Uri("http://example.com")))
{
    // Disallow entry of this URI into the system,
    // or log an alert, or whatever you want to do with it.
}

Checking if a URI is considered unsafe.

Next, check whether the IP addresses the URI resolves to are safe. You can resolve the host and run each resulting address through IsUnsafeIpAddress, which checks whether it falls into any known unsafe network, or matches an explicitly blocked IP.

if (Ssrf.IsUnsafeIpAddress(IPAddress.Parse("127.0.0.1")))
{
    // Disallow this IP address from being used in the system,
    // or log an alert, or whatever you want to do with it.
}

Checking if an IP address is considered unsafe

If you don’t want to write the “resolve + loop + check” logic yourself, you can use IsUnsafe. It validates the URI, resolves the host, checks every resolved IP address, and returns true or false.

if (Ssrf.IsUnsafe(new Uri("http://example.com")))
{
    // Disallow entry of this URI into the system,
    // or log an alert, or whatever you want to do with it.
}

Checking if a URI and the IPs it resolves to are considered unsafe

At this point it’s tempting to think: “Great—validate the URI when it comes in, and we’re done.”

But there’s still a catch: DNS can change.

If you only validate at the point of entry, you can still end up with a time-of-check, time-of-use (TOCTOU) issue: the hostname can resolve to safe IPs today and unsafe IPs later. An attacker could register a DNS name that looks harmless during submission, then change it days later to point at 127.0.0.1, 169.254.169.254, or something else internal.

To be fully protected, you need to validate the destination IP address right before the connection is made—whether that connection is for HTTP or WebSockets. In .NET, that’s where SocketsHttpHandler comes in.

SocketsHttpHandler (instead of the more typical HttpClientHandler) lets you intercept connection establishment via ConnectCallback.

Inside ConnectCallback you can validate the outbound destination. If the URI contains a hostname (rather than an IP address), resolve it, check each resulting IP, and then attempt connections only to the safe addresses. If none are safe—or none of the safe addresses connect—the request fails.

SsrfSocketsHttpHanderFactory gives you a handler that does all this:

using (var httpClient = new HttpClient(
    SsrfSocketsHttpHanderFactory.Create()))
{
    HttpResponseMessage response = await httpClient.GetAsync(
        new Uri("https://example.com"));
}

Using SsrfSocketsHttpHandlerFactory to build an HttpClient with SSRF protection

The Create() method can has some optional parameters, including

  • connectionStrategy : prefer IPv4, IPv6 or randomise which order the connections are attempted.
  • additionalUnsafeNetworks : add your own IP Networks to the unsafe list.
  • additionalUnsafeIPAddresses : treat additional individual IP addresses as unsafe
  • connectTimeout : how long to wait during connection establishment before giving up.
  • allowInsecureProtocals : allow http:// and ws:// schemes in URIs.
  • failMixedResults : if DNS resolves to both safe and unsafe IPs, either fail immediately (true, the default) or drop unsafe IPs and try only the safe ones (false).
  • allowAutoRedirect, automaticDecompression, proxy and sslOptions : these mirror the options of the same names on HttpClientHandler

As this returns a SocketsHttpHandler, it must be the final handler in the chain if you’re using delegating handlers.

To use it with ClientWebSocket, create an HttpClient that uses the handler, then pass that HttpClient into ConnectAsync() via the invoker parameter.

using (var webSocket = new ClientWebSocket())
using (var invoker = new HttpClient(SsrfSocketsHttpHanderFactory.Create()))
{
    await webSocket.ConnectAsync(
        uri: "wss://echo.websocket.org",
        invoker: invoker);
}

Configuring a ClientWebSocket to use the SsrfSocketHttpHandler

Why is this implemented as a factory? SocketsHttpHandler is a sealed class.

TLDR

  1. Add the idunno.Security.Ssrf NuGet package to your application.
  2. When accepting a URI as untrusted input, start with Ssrf.IsUnsafe(Uri uri) (or the more focused IsUnsafeUri/IsUnsafeIpAddress checks) to reject obviously unsafe values.
  3. When creating an HttpClient or ClientWebSocket, use SsrfSocketsHttpHandlerFactory to create a handler that blocks connections to well-known unsafe IP ranges and addresses at connection time.

Subscribe to Ramblings from a .NET Security PM

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe