Building SSRF-Resistant HTTP Clients in .NET

Applications increasingly accept URLs from outside their own code. A webhook tester calls an endpoint supplied by a customer. An image service downloads a remote avatar. A document pipeline retrieves an attachment from a link. The implementation often looks harmless:
app.MapPost("/fetch", async (FetchRequest request, HttpClient client) =>
{
var content = await client.GetStringAsync(request.Url);
return Results.Text(content);
});
The problem is that the caller has gained partial control over a network client running inside your infrastructure. They may be able to make it call an internal API, probe private ports or reach a cloud metadata service that isn't exposed to the internet. This is server-side request forgery, usually shortened to SSRF.
Protecting this code takes more than checking that the value starts with https://. A useful defence needs to cover the URL, DNS resolution, the address used for the connection, redirects and the network environment around the application.
What SSRF gives an attacker
Imagine a public endpoint that creates a preview for a supplied URL. A legitimate request points it towards a public article. A malicious request instead uses an address such as http://127.0.0.1, a private RFC 1918 address or the link-local address used by a cloud metadata service. The target system sees the request as coming from the application, not from the original caller. That application may sit on a trusted network, have access to internal services or possess an identity that those services trust. Even when the response isn't returned, differences in status codes and timing can turn the feature into a network scanner. This is sometimes called blind SSRF.
The OWASP SSRF Prevention Cheat Sheet separates applications into two broad cases. If the application only needs to call known services, an allowlist is the strongest starting point. If users genuinely need to supply arbitrary public destinations, validation becomes more involved and should be backed by network controls.
Start with the smallest possible destination policy
Before writing an IP-address validator, decide whether the caller needs to submit a complete URL at all. If the destination is one of your own integrations, accept an identifier and look up the URL from trusted configuration:
public sealed record DeliveryRequest(string IntegrationId, string Payload);
public sealed class IntegrationRegistry(IConfiguration configuration)
{
public Uri GetEndpoint(string integrationId) => integrationId switch
{
"accounts" => new Uri(configuration["Integrations:Accounts"]!),
"claims" => new Uri(configuration["Integrations:Claims"]!),
_ => throw new InvalidOperationException("Unknown integration.")
};
}
This removes destination selection from untrusted input. Where a complete user-supplied URL is a genuine requirement, define a precise policy. A typical public-content fetcher may allow HTTPS only, port 443 only, no embedded credentials, no fragments and either a hostname allowlist or public IP addresses only.
Parsing must be done with Uri. String checks are easy to bypass with unusual URL forms, encoded characters and misleading hostnames.
public sealed class OutboundUrlPolicy
{
private static readonly HashSet<string> AllowedHosts =
new(StringComparer.OrdinalIgnoreCase)
{
"images.example.com",
"documents.example.net"
};
public static Uri ParseAndValidate(string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("The URL is invalid.");
}
if (uri.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("Only HTTPS URLs are allowed.");
}
if (uri.Port != 443)
{
throw new InvalidOperationException("Only port 443 is allowed.");
}
if (!string.IsNullOrEmpty(uri.UserInfo))
{
throw new InvalidOperationException("Credentials aren't allowed in the URL.");
}
if (!string.IsNullOrEmpty(uri.Fragment))
{
throw new InvalidOperationException("URL fragments aren't allowed.");
}
if (!AllowedHosts.Contains(uri.IdnHost))
{
throw new InvalidOperationException("The destination isn't allowed.");
}
return uri;
}
}
Compare complete, canonical hostnames. A check such as host.EndsWith("example.com") also accepts notexample.com. If subdomains are required, accept either the exact parent or a name ending in . followed by the parent. Avoid regular expressions when a direct comparison expresses the rule more clearly. An allowlisted hostname can still be misconfigured or compromised through DNS, so the connection address should also be checked.
Why resolving the hostname before HttpClient isn't enough
A common SSRF defence resolves the hostname, rejects private addresses and then gives the original URL to HttpClient:
var addresses = await Dns.GetHostAddressesAsync(uri.Host);
Validate(addresses);
return await httpClient.GetAsync(uri);
There are two separate DNS lookups here. Your validation code performs the first one. HttpClient may perform another when it opens the connection. An attacker who controls DNS can return a public address during validation and a private address for the connection. This is DNS rebinding, or more precisely a time-of-check/time-of-use problem. The validation must be tied to the address actually used by the socket. SocketsHttpHandler.ConnectCallback gives .NET applications a suitable interception point.
Validate at connection time
The following validator rejects address categories that an internet only fetcher shouldn't contact. It handles IPv4-mapped IPv6 addresses before applying the IPv4 rules.
using System.Net;
using System.Net.Sockets;
public static class PublicAddressPolicy
{
public static bool IsAllowed(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IPAddress.IsLoopback(address) ||
address.Equals(IPAddress.Any) ||
address.Equals(IPAddress.IPv6Any) ||
address.Equals(IPAddress.None) ||
address.Equals(IPAddress.IPv6None))
{
return false;
}
var bytes = address.GetAddressBytes();
if (address.AddressFamily == AddressFamily.InterNetwork)
{
return !IsPrivateOrSpecialIpv4(bytes);
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
return !address.IsIPv6LinkLocal &&
!address.IsIPv6Multicast &&
!IsUniqueLocalIpv6(bytes);
}
return false;
}
private static bool IsPrivateOrSpecialIpv4(byte[] address) =>
address[0] == 0 || // current network
address[0] == 10 || // 10.0.0.0/8
address[0] == 127 || // loopback
address[0] == 169 && address[1] == 254 || // link-local
address[0] == 172 && address[1] is >= 16 and <= 31 || // 172.16.0.0/12
address[0] == 192 && address[1] == 168 || // 192.168.0.0/16
address[0] == 100 && address[1] is >= 64 and <= 127 || // shared address space
address[0] >= 224; // multicast/reserved
private static bool IsUniqueLocalIpv6(byte[] address) =>
(address[0] & 0xFE) == 0xFC; // fc00::/7
}
Address classification is easy to get subtly wrong. The example covers the ranges most relevant to SSRF, but a production internet-only policy should be tested against the complete set of special-purpose ranges relevant to the networks where it runs. A maintained IP/CIDR library or a centrally managed egress proxy can be preferable to duplicating this logic across applications.
We can now connect only after resolving and approving the destination address:
using System.Net;
using System.Net.Sockets;
public static class SafeSocketConnector
{
public static async ValueTask<Stream> ConnectAsync(
SocketsHttpConnectionContext context,
CancellationToken stopToken)
{
var endpoint = context.DnsEndPoint;
var addresses = await Dns.GetHostAddressesAsync(
endpoint.Host,
stopToken);
var allowedAddresses = addresses
.Where(PublicAddressPolicy.IsAllowed)
.ToArray();
if (allowedAddresses.Length != addresses.Length ||
allowedAddresses.Length == 0)
{
throw new HttpRequestException(
"The destination resolved to a blocked address.");
}
Exception? lastError = null;
foreach (var address in allowedAddresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true
};
try
{
await socket.ConnectAsync(
new IPEndPoint(address, endpoint.Port),
stopToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch (Exception error) when (error is SocketException or OperationCanceledException)
{
socket.Dispose();
lastError = error;
if (error is OperationCanceledException)
{
throw;
}
}
}
throw new HttpRequestException(
"A connection couldn't be established to the destination.",
lastError);
}
}
The strict allowedAddresses.Length != addresses.Length check rejects a hostname if any returned address is blocked. This avoids choosing a public answer while leaving a mixed public/private DNS configuration unnoticed. That strictness can expose broken DNS configurations, which is useful for an allowlisted integration. For a general public fetcher, define and document the behaviour deliberately.
Register a dedicated client with IHttpClientFactory
SSRF-sensitive traffic should use its own named or typed client. This makes its handler policy difficult to bypass accidentally and keeps credentials intended for other services away from attacker-influenced requests.
builder.Services.AddHttpClient<SafeContentClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.UserAgent.ParseAdd("ContentFetcher/1.0");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
ConnectTimeout = TimeSpan.FromSeconds(5),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
ConnectCallback = SafeSocketConnector.ConnectAsync
});
The client should also cap how much data it reads. ResponseHeadersRead prevents HttpClient from buffering the entire response before your code can enforce a limit.
public sealed class SafeContentClient(HttpClient httpClient)
{
private const int MaximumResponseBytes = 5 * 1024 * 1024;
public async Task<byte[]> DownloadAsync(
string untrustedUrl,
CancellationToken stopToken)
{
var uri = OutboundUrlPolicy.ParseAndValidate(untrustedUrl);
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
using var response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
stopToken);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is > MaximumResponseBytes)
{
throw new InvalidOperationException("The response is too large.");
}
await using var source = await response.Content.ReadAsStreamAsync(stopToken);
await using var destination = new MemoryStream();
var buffer = new byte[81920];
var totalBytes = 0;
while (true)
{
var bytesRead = await source.ReadAsync(buffer, stopToken);
if (bytesRead == 0)
{
break;
}
totalBytes += bytesRead;
if (totalBytes > MaximumResponseBytes)
{
throw new InvalidOperationException("The response is too large.");
}
await destination.WriteAsync(buffer.AsMemory(0, bytesRead), stopToken);
}
return destination.ToArray();
}
}
Checking Content-Length alone isn't sufficient because the header can be absent or dishonest. The streaming loop enforces the limit on the bytes actually read. Disabling automatic decompression also avoids silently expanding a small compressed response into a much larger body. If compressed content is a requirement, apply a limit after decompression as well.
Redirects are new requests, not part of the old one
Automatic redirects are dangerous in an SSRF-sensitive client. An allowed public URL can return a redirect to https://127.0.0.1, an internal hostname or a different port. When AllowAutoRedirect is enabled, the application may follow it before its own URL policy sees the new destination.
The simplest policy is to reject redirects. If the feature needs them, disable automatic redirects and handle a small number manually. Resolve relative Location values against the current URI, run the full URL policy again and send a new request. The connection callback must still validate the newly resolved address.
Be particularly careful with headers across redirects. Don't forward Authorization, cookies or other service credentials to a new host. In many fetcher-style applications, the safest client has no ambient credentials at all.
IHttpClientFactory helps, but it isn't an SSRF control by itself
IHttpClientFactory manages handler lifetimes and makes named or typed clients easy to configure. It doesn't decide whether a destination is trustworthy. Registering a client through the factory doesn't prevent calls to loopback, private networks or metadata endpoints unless its handler and surrounding network enforce that policy. Connection pooling also deserves attention. A pooled connection doesn't perform DNS resolution for every request because it reuses an existing socket. This isn't a bypass when the socket was validated as it was created, but it affects how quickly DNS changes take effect. PooledConnectionLifetime places a limit on reuse. Choose the value according to the destination's DNS behaviour and your operational requirements.
Add network level enforcement
Application validation is only one layer. OWASP recommends restricting the application's outbound network access so it can reach only the destinations it requires. In Azure, that may mean routing outbound traffic through Azure Firewall or another controlled egress component, applying network security rules and explicitly denying access to internal and link local ranges from the workload subnet. This layer protects you if a new code path creates a plain HttpClient, a parser misses an address form or a dependency makes an unexpected outbound request. It also provides a central place for logging and destination policy.
For a service that only calls several known APIs, the network allowlist can be narrow. A public URL fetching service is a different workload. Isolating it into a separate process or subnet with no route to internal systems limits what a successful bypass can reach. Cloud metadata endpoints deserve explicit attention. Azure services use platform specific mechanisms and headers to protect managed identity token endpoints, but application code shouldn't rely on those measures as its SSRF defence. Block link local access from untrusted fetch operations and use the supported identity SDK from trusted code.
Test the policy as security code
Unit tests should cover more than the obvious 127.0.0.1 case. Include IPv4 and IPv6 loopback, RFC 1918 addresses, link-local addresses, IPv4-mapped IPv6, unique-local IPv6, non-HTTPS schemes, unexpected ports, embedded credentials and misleading hostname suffixes.
public sealed class PublicAddressPolicyTests
{
[Theory]
[InlineData("127.0.0.1")]
[InlineData("10.20.30.40")]
[InlineData("169.254.169.254")]
[InlineData("192.168.1.10")]
[InlineData("::1")]
[InlineData("fc00::1")]
[InlineData("::ffff:127.0.0.1")]
public void IsAllowed_BlocksNonPublicAddresses(string value)
{
var address = IPAddress.Parse(value);
Assert.False(PublicAddressPolicy.IsAllowed(address));
}
[Theory]
[InlineData("1.1.1.1")]
[InlineData("8.8.8.8")]
[InlineData("2606:4700:4700::1111")]
public void IsAllowed_AcceptsPublicAddresses(string value)
{
var address = IPAddress.Parse(value);
Assert.True(PublicAddressPolicy.IsAllowed(address));
}
}
Integration tests should run against a controlled DNS server and HTTP service. Test a hostname that resolves to a blocked address, a mixed public/private response and a public endpoint that redirects to a blocked destination. These cases exercise the connection boundary that unit tests of the URL parser can't reach. Log blocked attempts using structured fields such as the normalised hostname, resolved address category and policy reason. Avoid logging embedded credentials or the full query string because URLs often contain sensitive tokens.
A practical review checklist
When reviewing an outbound HTTP feature, trace where the destination comes from and how much control the caller has. Prefer a trusted destination identifier over a supplied URL. If URLs are accepted, parse them with Uri, restrict the scheme and port, compare canonical hostnames and reject credentials. Then follow the request beyond validation. Confirm that every resolved address is acceptable at socket connection time, automatic redirects are disabled, credentials aren't attached to the client, response size and time are bounded, and outbound network policy blocks internal destinations. Finally, test the awkward address forms and redirect paths rather than only the normal request.
IHttpClientFactory is a good place to package this behaviour into a dedicated client. The actual protection comes from treating destination selection as a security boundary and enforcing the same policy from URL parsing through to the socket and the surrounding network.




