CWE-918: Server-Side Request Forgery (SSRF) - C#
Overview
Server-Side Request Forgery (SSRF) allows attackers to make the server perform HTTP requests to arbitrary destinations, potentially accessing internal services, cloud metadata endpoints, or bypassing firewalls. Always validate URLs against an allowlist, block private IP ranges, and implement network segmentation.
Primary Defence: Validate URLs against an allowlist of permitted domains/IPs, block private, loopback, link-local, and reserved ranges, disable or validate redirects, and use egress controls or connection-time validation to close DNS rebinding gaps.
Common Vulnerable Patterns
Direct URL Usage from User Input
// VULNERABLE - No validation on user-provided URL
using System.Net;
public class ImageFetcher
{
public byte[] FetchImage(string imageUrl)
{
// No validation - SSRF vulnerability!
using var client = new WebClient();
return client.DownloadData(imageUrl);
}
}
// Attack examples:
// http://localhost/admin
// http://169.254.169.254/latest/meta-data/iam/security-credentials/
// file:///C:/Windows/System.ini
Why this is vulnerable: Accepting user-provided URLs without validation allows attackers to make the server request internal resources (localhost, 169.254.169.254 cloud metadata, internal IPs), bypass firewalls, or access local files.
Unvalidated HttpClient Requests
// VULNERABLE - HttpClient without URL validation
using System.Net.Http;
public class WebhookHandler
{
private readonly HttpClient _httpClient = new HttpClient();
public async Task SendWebhookAsync(string webhookUrl, string data)
{
// No validation - SSRF vulnerability!
var response = await _httpClient.PostAsync(
webhookUrl,
new StringContent(data)
);
}
}
// Attack: webhookUrl = "http://internal-api.local/sensitive-endpoint"
Why this is vulnerable: HttpClient posts to whatever host the webhook URL names, so a caller can aim it at a service only reachable from inside the network - internal-api.local above - and the POST arrives with the server's network position behind it.
ASP.NET Core Controller Without Validation
// VULNERABLE - No URL validation in controller
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class ProxyController : ControllerBase
{
private readonly HttpClient _httpClient;
public ProxyController(IHttpClientFactory factory)
{
_httpClient = factory.CreateClient();
}
[HttpGet]
public async Task<IActionResult> Proxy([FromQuery] string url)
{
// No validation - SSRF vulnerability!
var response = await _httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
return Ok(content);
}
}
// Attack: /api/proxy?url=http://169.254.169.254/latest/meta-data/
Why this is vulnerable: The url query parameter goes straight to GetAsync, and the response body is returned to the caller - so ?url=http://169.254.169.254/latest/meta-data/ reads the metadata service from the server's network position and hands the answer back through the API.
Following Redirects Without Re-validation
// VULNERABLE - the client follows a redirect nobody validated
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Security;
using System.Threading.Tasks;
public class LinkPreviewFetcher
{
private static readonly HashSet<string> AllowedHosts = new() { "news.example.com" };
// HttpClientHandler and SocketsHttpHandler both default to
// AllowAutoRedirect = true, so a plain HttpClient follows 3xx on its own
private readonly HttpClient _client = new HttpClient();
public async Task<string> PreviewAsync(string pageUrl)
{
var uri = new Uri(pageUrl);
if (uri.Scheme != Uri.UriSchemeHttps || !AllowedHosts.Contains(uri.Host.ToLowerInvariant()))
{
throw new SecurityException("URL not allowed");
}
// The check above ran once, against this Uri. The client follows the
// 302 below to a Uri nothing checked
return await _client.GetStringAsync(uri);
}
}
// Attack: a page on the allowlisted host answers
// 302 Location: http://169.254.169.254/latest/meta-data/
// and the client fetches it with the server's network position
Why this is vulnerable: The validation ran against the URL the caller supplied, and the client then made a second request to a URL it took from the response. Any allowlisted host that can be made to redirect - an open redirect on it, a user-controlled short link, a compromised page - hands the client to the metadata service. An open redirect on this application is the neighbouring weakness, CWE-601; it becomes SSRF only when something server-side follows it, and the fetcher above is that something.
Secure Patterns
The Address Predicate, in One Place
Every pattern below needs the same question answered - is this address one the application may connect to - so it is worth having exactly one answer to it. Two copies of a range list become two lists to maintain, and the one that gets forgotten is the one a scanner never looks at.
// SECURE - one predicate, called from validation and from connection time
using System;
using System.Linq;
using System.Net;
using System.Net.Sockets;
public static class SsrfAddressPolicy
{
// The rule is "not a public unicast address", not "not private": reserved
// and special-purpose ranges are just as reachable from inside a network
public static bool IsBlocked(IPAddress address)
{
var addr = Unwrap(address);
if (addr.IsIPv6LinkLocal || // fe80::/10
addr.IsIPv6SiteLocal || // fec0::/10, deprecated in 2004
addr.IsIPv6UniqueLocal || // fc00::/7, where IPv6 private space is
addr.IsIPv6Multicast || // ff00::/8
IPAddress.IsLoopback(addr) || // 127.0.0.0/8, ::1
addr.Equals(IPAddress.Any) || // 0.0.0.0
addr.Equals(IPAddress.IPv6Any)) // ::
{
return true;
}
if (addr.AddressFamily != AddressFamily.InterNetwork)
{
// IPv6 ranges that carry an IPv4 address in their low bits. Unwrap
// below folds ::ffff:127.0.0.1 and ::7f00:1 to IPv4, but these are
// left alone and each can spell 127.0.0.1. Only the NAT64
// local-use prefix may carry a non-global IPv4 address through a
// compliant translator (RFC 8215); RFC 6052 section 3.1 requires
// the well-known-prefix form to be dropped. Refused as encodings.
//
// The two NAT64 prefixes are separate entries in IANA's IPv6
// special-purpose registry, so match them rather than the /32 they
// sit in - over-blocking is the same defect as under-blocking with
// the sign flipped
var v6 = addr.GetAddressBytes();
int word0 = (v6[0] << 8) | v6[1];
int word1 = (v6[2] << 8) | v6[3];
int word2 = (v6[4] << 8) | v6[5];
// Documentation (2001:db8::/32, RFC 3849) and discard-only (100::/64,
// RFC 6666): nothing legitimate lives in either, and a filter that
// admits them has a gap no must-allow test will notice
if (word0 == 0x2001 && word1 == 0x0db8) return true;
if (word0 == 0x0100 && word1 == 0 && word2 == 0 && v6[6] == 0 && v6[7] == 0) return true;
// ::ffff:0:a.b.c.d - the IPv4-translated form (RFC 6145), one zero
// group on from the mapped form .NET folds. Nothing routes it to a
// host, so the whole prefix is refused rather than unwrapped
if (word0 == 0 && word1 == 0 && word2 == 0 && v6[6] == 0 && v6[7] == 0 &&
v6[8] == 0xff && v6[9] == 0xff && v6[10] == 0 && v6[11] == 0)
{
return true;
}
if (word0 == 0x0064 && word1 == 0xff9b)
{
if (word2 == 0x0001) return true; // 64:ff9b:1::/48, RFC 8215
if (word2 != 0x0000) return false;
return v6.Skip(6).Take(6).All(octet => octet == 0); // 64:ff9b::/96, RFC 6052
}
return word0 == 0x2002 || // 6to4, RFC 3056
(word0 == 0x2001 && word1 == 0x0000); // Teredo 2001::/32
}
var b = addr.GetAddressBytes();
return b[0] == 0 || // 0.0.0.0/8 "this network"
b[0] == 10 || // RFC 1918
b[0] == 127 || // loopback
(b[0] == 100 && b[1] >= 64 && b[1] <= 127) || // 100.64.0.0/10 carrier-grade NAT
(b[0] == 198 && (b[1] == 18 || b[1] == 19)) || // 198.18.0.0/15 benchmarking
(b[0] == 169 && b[1] == 254) || // link-local, incl. 169.254.169.254
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) || // RFC 1918
(b[0] == 192 && b[1] == 0 && b[2] == 0) || // 192.0.0.0/24 IETF assignments
(b[0] == 192 && b[1] == 0 && b[2] == 2) || // 192.0.2.0/24 documentation (RFC 5737)
(b[0] == 198 && b[1] == 51 && b[2] == 100) || // 198.51.100.0/24 documentation
(b[0] == 203 && b[1] == 0 && b[2] == 113) || // 203.0.113.0/24 documentation
(b[0] == 192 && b[1] == 168) || // RFC 1918
b[0] >= 224; // 224/4 multicast, 240/4 reserved
}
// Fail closed: a host that will not resolve is not a host to connect to
public static bool ResolvesToBlocked(string host)
{
try
{
return Dns.GetHostAddresses(host).Any(IsBlocked);
}
catch (SocketException)
{
return true;
}
catch (ArgumentException)
{
return true;
}
}
// An IPv6 address can carry an IPv4 one in six forms, and only two of
// them unwrap. .NET does not fold the mapped form on parse: measured on
// .NET 10, IPAddress.Parse("::ffff:127.0.0.1") stays InterNetworkV6 with
// IsIPv4MappedToIPv6 true, and IPAddress.IsLoopback is true for that one
// spelling only through an equality test - it is false for
// ::ffff:127.0.0.2. MapToIPv4() is what makes the IPv4 rules above apply.
// The compatible form (::7f00:1) has no property at all, and every
// property above returns false for it, which is what the byte test below
// is for. The NAT64, 6to4, Teredo and
// IPv4-translated forms carry the IPv4 address inside a prefix that is an
// ordinary IPv6 address, so they are matched by prefix above rather than
// unwrapped here
private static IPAddress Unwrap(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetworkV6)
return address;
if (address.IsIPv4MappedToIPv6)
return address.MapToIPv4();
var b = address.GetAddressBytes();
for (int i = 0; i < 12; i++)
{
if (b[i] != 0) return address;
}
// :: and ::1 are the unspecified and loopback addresses, not IPv4 - and
// unwrapping ::1 to 0.0.0.1 would lose what made it worth blocking
if ((b[12] | b[13] | b[14]) == 0 && b[15] <= 1) return address;
return new IPAddress(new[] { b[12], b[13], b[14], b[15] });
}
}
Why this works:
- One list, three call sites. The allowlist validators below and the
ConnectCallbackat the end of the page all callIsBlocked, so a range added here is added everywhere. The gaps that turn up in SSRF filters are almost never in the famous ranges. - Framework properties where they exist, byte tests where they do not.
IsIPv6UniqueLocal(.NET 6+) is the one people miss, becauseIsIPv6SiteLocalsounds like it covers private IPv6 and only matches the deprecatedfec0::/10. There is no property at all for0.0.0.0/8,100.64.0.0/10,192.0.0.0/24,198.18.0.0/15, the three RFC 5737 documentation ranges,2001:db8::/32or100::/64. 169.254.0.0/16rather than a check for169.254.169.254. The whole link-local range goes, which covers the Azure and Alibaba metadata addresses as well as the AWS one, and anything else listening on that interface.- Fail closed on resolution failure.
ResolvesToBlockedreturns true when DNS raises, so a name that cannot be checked is not fetched.
URL Allowlist Validation
// SECURE - Validate URLs against allowlist
using System;
using System.Net;
using System.Net.Http;
using System.Collections.Generic;
using System.Security;
public class SafeImageFetcher
{
private static readonly HashSet<string> AllowedHosts = new()
{
"api.example.com",
"cdn.example.com",
"images.example.com"
};
private static readonly HashSet<string> AllowedSchemes = new() { "https" };
public byte[] FetchImage(string imageUrl)
{
var validatedUri = ValidateUrl(imageUrl);
// SsrfHttpHandler (defined under "Validating the Address Actually
// Connected To" below) turns redirects and the environment proxy off
// and dials only addresses that pass SsrfAddressPolicy - so the lookup
// ValidateUrl made is not the last check, and a second DNS answer is
// inspected on the same terms as the first
using var client = new HttpClient(SsrfHttpHandler.Create())
{
Timeout = TimeSpan.FromSeconds(10)
};
return client.GetByteArrayAsync(validatedUri).GetAwaiter().GetResult();
}
private Uri ValidateUrl(string urlString)
{
if (!Uri.TryCreate(urlString, UriKind.Absolute, out Uri? uri))
{
throw new SecurityException("Invalid URL");
}
// Validate scheme
if (!AllowedSchemes.Contains(uri.Scheme.ToLowerInvariant()))
{
throw new SecurityException($"Invalid URL scheme: {uri.Scheme}");
}
// Validate host
string host = uri.Host.ToLowerInvariant();
if (!AllowedHosts.Contains(host))
{
throw new SecurityException($"Host not allowed: {host}");
}
// Reject anything that resolves somewhere non-public
if (SsrfAddressPolicy.ResolvesToBlocked(uri.Host))
{
throw new SecurityException("Host does not resolve to a public address");
}
return uri;
}
}
Why this works:
- Host allowlist: Prevents arbitrary URLs targeting internal services, cloud metadata (169.254.169.254), or localhost
- Scheme validation: Blocks
file://,ftp://,gopher://and other protocols that could read local files or exploit legacy services - DNS resolution defense:
ResolvesToBlockedchecks every answerDns.GetHostAddresses()returns, not the first, andSsrfHttpHandlerrepeats the check on every address the connection dials - so the answer that is connected to has been inspected, not only the answer that was looked up beforehand - One shared range list: the ranges live in
SsrfAddressPolicyrather than in this class, so this validator and theConnectCallbackat the end of the page cannot disagree about what counts as internal - Fail-closed: Blocks on DNS errors
- No environment proxy:
SsrfHttpHandlersetsUseProxy = false. The validation established whatimageUrlresolves to from this process; a proxy makes that irrelevant - the hostname is forwarded and resolved at the other end - and .NET opts into one from the environment without being asked - Defense-in-depth: Scheme, host allowlist and address checks each have to pass, so getting past one of them is not enough to reach the metadata service
HttpClient with Validation
// SECURE - HttpClient with URL validation and restrictions
using System;
using System.Net.Http;
using System.Security;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
public class SecureWebhookHandler
{
private readonly HttpClient _httpClient;
// \z, not $: in .NET a $ anchor also matches before a trailing newline, so
// "https://api.example.com/hook" with a newline appended passes $ and
// fails \z - measured on .NET 10
private static readonly Regex AllowedUrlPattern =
new(@"^https://([a-z0-9-]+\.)*example\.com/.*\z", RegexOptions.IgnoreCase);
public SecureWebhookHandler()
{
// SsrfHttpHandler (defined under "Validating the Address Actually
// Connected To" below): redirects off, no proxy from the environment -
// a proxy resolves the target itself, so the addresses
// ValidateWebhookUrl checked would not be the ones reached - and every
// address the connection dials re-checked against SsrfAddressPolicy
_httpClient = new HttpClient(SsrfHttpHandler.Create())
{
Timeout = TimeSpan.FromSeconds(10)
};
}
public async Task SendWebhookAsync(string webhookUrl, string data)
{
var validatedUri = ValidateWebhookUrl(webhookUrl);
var content = new StringContent(data);
var response = await _httpClient.PostAsync(validatedUri, content);
response.EnsureSuccessStatusCode();
}
private Uri ValidateWebhookUrl(string url)
{
if (string.IsNullOrEmpty(url))
{
throw new SecurityException("URL cannot be empty");
}
// Check against allowlist pattern
if (!AllowedUrlPattern.IsMatch(url))
{
throw new SecurityException($"URL not allowed: {url}");
}
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri))
{
throw new SecurityException("Invalid URL");
}
// Only HTTPS
if (uri.Scheme != Uri.UriSchemeHttps)
{
throw new SecurityException("Only HTTPS allowed");
}
// Block anything that resolves somewhere non-public
if (SsrfAddressPolicy.ResolvesToBlocked(uri.Host))
{
throw new SecurityException("Host does not resolve to a public address");
}
return uri;
}
}
Why this works:
- Redirect blocking:
SsrfHttpHandlersetsAllowAutoRedirect = false, so an allowed public URL redirecting to a private endpoint (e.g.,https://example.com/redirect?to=http://localhost:6379) surfaces as a 3xx rather than a second fetch - The connection is pinned: the handler's
ConnectCallbackresolves the name itself and refuses any answerSsrfAddressPolicyrejects, soValidateWebhookUrl's lookup is the first check rather than the only one - Strict domain matching: The anchored pattern matches only
example.comand its subdomains, so a lookalike host such asexample-com.evil.com, or a URL carryingexample.comin its path, is rejected - Timeout protection: Prevents SSRF-based DoS where attackers target slow internal services to exhaust server resources
- HTTPS-only validation: Protects credentials in transit and prevents downgrade attacks
- Address checking delegated, not repeated: the ranges are in
SsrfAddressPolicy. A copy here would be a second list to keep current, and the copy that falls behind is the one nothing tests - DNS exception handling: Prevents attackers using DNS timeouts to bypass validation
ASP.NET Core with Validation
// SECURE - ASP.NET Core controller with URL validation
using System.Security;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class SecureProxyController : ControllerBase
{
private readonly HttpClient _httpClient;
private readonly IUrlValidator _urlValidator;
public SecureProxyController(
IHttpClientFactory factory,
IUrlValidator urlValidator)
{
// Named, not factory.CreateClient(). The unnamed client is the
// default configuration: redirects followed, and a proxy taken from
// http_proxy/https_proxy. "SecureClient" is registered with a handler
// that turns both off - see Named HttpClient with Restrictions below
_httpClient = factory.CreateClient("SecureClient");
_urlValidator = urlValidator;
}
[HttpGet]
public async Task<IActionResult> Proxy([FromQuery] string url)
{
try
{
// Validate URL against allowlist and block private IPs
var validatedUri = _urlValidator.Validate(url);
var response = await _httpClient.GetAsync(validatedUri);
if (!response.IsSuccessStatusCode)
{
return StatusCode((int)response.StatusCode);
}
var content = await response.Content.ReadAsStringAsync();
return Ok(content);
}
catch (SecurityException ex)
{
return BadRequest(new { error = "Invalid URL" });
}
}
}
// IUrlValidator interface. An implementation follows the validators above:
// allowlist the host, allow only HTTPS, and call
// SsrfAddressPolicy.ResolvesToBlocked on the host so the range list is not
// reinvented per implementation
public interface IUrlValidator
{
Uri Validate(string url);
}
Why this works:
- Centralized logic:
IUrlValidatordependency injection makes SSRF protection testable and reusable across controllers - The client is named, and the name is the control:
factory.CreateClient()with no argument returns a client with default handler settings, so a controller that validates carefully can still follow a redirect to an internal address or send the request through a proxy that resolves the target itself. Registering the restrictions against a name and asking for that name is what makes the handler configuration reachable from here - and a typo in the name yields a default client rather than an error, which is worth a test asserting a 3xx is not followed - Flexible abstraction: the interface lets the allowlist and the address policy be supplied per deployment without code changes. Keep the validation itself identical everywhere - an environment that relaxes it stops testing the thing production relies on
- Connection pooling:
IHttpClientFactoryensures HttpClient instances pooled and configured consistently, preventing socket exhaustion - Global policies: Enforces consistent timeouts and handlers across all HTTP requests
- Information disclosure prevention: The
SecurityExceptioncatch returns one generic message, so an attacker cannot tell whether the URL was rejected by the allowlist, by the private-IP check, or by a DNS failure, and gets nothing to iterate against - Appropriate error code: 400 Bad Request says the request was at fault without naming which check rejected it
Protecting Cloud Metadata Endpoints
// SECURE - Block AWS/Azure/GCP metadata endpoints
using System;
using System.Collections.Generic;
using System.Net;
using System.Security;
public class MetadataProtection
{
private static readonly HashSet<string> BlockedHosts = new()
{
"169.254.169.254", // AWS/Azure metadata
"metadata.google.internal", // GCP metadata
"metadata"
};
private static readonly HashSet<string> BlockedPaths = new()
{
"/latest/meta-data",
"/latest/user-data",
"/latest/dynamic",
"/computeMetadata/v1",
"/metadata/instance"
};
public void ValidateNotMetadata(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri))
{
throw new SecurityException("Invalid URL");
}
string host = uri.Host.ToLowerInvariant();
string path = uri.AbsolutePath;
// Block metadata service hostnames
if (BlockedHosts.Contains(host))
{
throw new SecurityException("Access to metadata service blocked");
}
// Block metadata paths
foreach (var blockedPath in BlockedPaths)
{
if (path.StartsWith(blockedPath, StringComparison.OrdinalIgnoreCase))
{
throw new SecurityException("Access to metadata endpoint blocked");
}
}
// Every answer goes through the one policy. A local check here would
// be a second range list to keep current, and the obvious hand-written
// version - IPv4 answers only, 169.254.0.0/16 by byte - skips every
// IPv6 answer, so [64:ff9b:1::a9fe:a9fe], the NAT64 local-use spelling
// of the metadata address this method exists to block, walks past it
try
{
var addresses = Dns.GetHostAddresses(host);
if (addresses.Length == 0)
{
throw new SecurityException("Host does not resolve");
}
foreach (var addr in addresses)
{
if (SsrfAddressPolicy.IsBlocked(addr))
{
throw new SecurityException("Non-public address blocked");
}
}
}
catch (Exception ex) when (ex is not SecurityException)
{
throw new SecurityException("DNS resolution failed");
}
}
}
Why this works:
- Sensitive data exposure: Cloud metadata endpoints (AWS EC2, Azure VM, GCP Compute) expose IAM credentials, SSH keys, instance tags, user data scripts
- AWS metadata protection: Blocking 169.254.169.254 stops the
/latest/meta-data/iam/security-credentials/fetch that yields temporary IAM credentials - GCP metadata defense:
metadata.google.internalhostname blocking addresses Google's different resolution approach - Path-based blocking: Catches requests using IP directly or bypassing host checks via open redirects
- Full link-local range: The 169.254.0.0/16 check covers the entire range, not just
.254- clouds and custom services use other addresses in the block - DNS-based bypass prevention: A domain the attacker registered that resolves to 169.254.169.254 is caught by the resolution loop, which runs
SsrfAddressPolicy.IsBlockedover every answer - the hostname list on its own would not see it - Overlapping defenses: Host, path, and address checks are independent, so a bypass of any one of them still meets the other two
Named HttpClient with Restrictions (.NET 6+)
// SECURE - Configure HttpClient with restrictions to prevent SSRF bypasses
using Microsoft.Extensions.DependencyInjection;
// Program.cs or Startup.cs
builder.Services.AddHttpClient("SecureClient", client =>
{
// Timeout prevents long-running requests to internal services
client.Timeout = TimeSpan.FromSeconds(10);
})
// SsrfHttpHandler (defined under "Validating the Address Actually Connected
// To" below): AllowAutoRedirect = false, so a validated https://evil.com/safe
// that redirects to http://169.254.169.254/ is not followed; UseProxy = false,
// so no environment proxy resolves the target instead; and a ConnectCallback
// that re-checks every address the connection dials
.ConfigurePrimaryHttpMessageHandler(() => SsrfHttpHandler.Create())
.AddPolicyHandler(GetRetryPolicy());
// Service using named client
// IMPORTANT: HttpClient restrictions alone are NOT sufficient - must also validate URLs
public class ApiService
{
private readonly HttpClient _httpClient;
private readonly IUrlValidator _validator;
public ApiService(IHttpClientFactory factory, IUrlValidator validator)
{
_httpClient = factory.CreateClient("SecureClient");
_validator = validator;
}
public async Task<string> FetchDataAsync(string url)
{
// URL validation is REQUIRED - HttpClient config is defense in depth
var validatedUri = _validator.Validate(url);
var response = await _httpClient.GetAsync(validatedUri);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
Validating the Address Actually Connected To
Validation before the request is half of it. HttpClient resolves the hostname
again when it opens the connection, so unless the handler is told otherwise the
address that was checked and the address that is reached are two different
lookups - which is the DNS rebinding race. SocketsHttpHandler.ConnectCallback
(.NET 6+) closes it, because the callback owns the socket, and it is the handler
every client on this page is built on:
// SECURE - the handler decides which address is dialled, and inspects the peer
using System;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Security;
public static class SsrfHttpHandler
{
// Every HttpClient on this page is constructed on this handler
public static SocketsHttpHandler Create() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
// UseProxy = false, or the callback validates the wrong host. With a proxy
// configured - including one HttpClient.DefaultProxy picked up from
// http_proxy/https_proxy - context.DnsEndPoint is the proxy, not the target,
// so every check below passes on the proxy's address while the request the
// proxy forwards goes wherever the URL said
UseProxy = false,
ConnectCallback = async (context, cancellationToken) =>
{
var addresses = await Dns.GetHostAddressesAsync(
context.DnsEndPoint.Host, cancellationToken);
// Every answer must be acceptable, not just the one that gets used
foreach (var address in addresses)
{
if (SsrfAddressPolicy.IsBlocked(address))
throw new SecurityException($"Blocked non-global address {address}");
}
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
// Re-check what the socket actually reached, not what DNS promised
var peer = ((IPEndPoint)socket.RemoteEndPoint!).Address;
if (SsrfAddressPolicy.IsBlocked(peer))
throw new SecurityException($"Blocked non-global peer {peer}");
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
};
}
// Usage
using var client = new HttpClient(SsrfHttpHandler.Create()) { Timeout = TimeSpan.FromSeconds(20) };
Why this works:
- The callback performs the only DNS lookup. There is no later resolution for an attacker to answer differently, which is what makes this a fix rather than a narrower window.
RemoteEndPointis checked after connecting, so even a resolver that returns a different address than the one enumerated is caught before any request bytes are written.- It applies to every connection the client opens, including ones opened to follow a redirect if you choose to allow redirects.
- Use
SocketsHttpHandler, notHttpClientHandler.ConnectCallbackdoes not exist on the latter, which is why every client on this page is built onSsrfHttpHandler.Create(): the redirect and proxy settings would be the same on either handler, but only this one can carry the connection check. UseProxy = falseis what makes the callback meaningful. The callback is handed the endpoint the client is about to dial, and with a proxy in play that is the proxy. Everything is then validated correctly and about the wrong host: the proxy is public and reachable, so the checks pass, and the proxy resolves and fetches the real target itself. On .NET this does not require anyone to have written proxy code -HttpClient.DefaultProxyreadshttp_proxyandhttps_proxyfrom the environment, so a variable set in a container image is enough. Where a proxy is genuinely required for egress, that proxy is the place the destination control has to live.
Expected behaviour against a listener on 127.0.0.1, and a public host:
plain HttpClient -> internal OK (15 bytes) <- the vulnerability
ConnectCallback -> internal blocked: Blocked non-global address 127.0.0.1
ConnectCallback -> public OK 200
Assert both rows. A handler that blocks the internal address but also breaks the public request is indistinguishable from one that works until someone tries it.
The predicate is SsrfAddressPolicy.IsBlocked from
The Address Predicate, in One Place,
deliberately rather than a local reimplementation. A connection-time check is the
last thing to see the address before the socket opens, so any range it does not
know is a bypass with nothing behind it - and the ranges the framework has no
property for are exactly the ones a check written here would leave out.
Keep the allowlist as well. The predicate establishes only that an address is publicly routable, not that it is one you meant to contact.
Common Pitfalls
- Validating the URL's host with an allowlist check against
Uri.Hostbut then issuing the request through a plainnew HttpClient()-HttpClientHandler/SocketsHttpHandlerfollow redirects by default (AllowAutoRedirect = true), so a validated URL that responds with a redirect to an internal address is followed without the new destination ever being checked. - Resolving and validating the hostname with
Dns.GetHostAddresses()in application code, then lettingHttpClientperform its own separate DNS resolution when the request is actually sent - the two resolutions aren't guaranteed to return the same address, which is exactly the DNS-rebinding gap theSocketsHttpHandler.ConnectCallbackpattern above is designed to close; validating up front without pinning the connection to the validated address doesn't close it. - Running a regex or string check against the raw URL before parsing it with
Uri-Uri's own parsing and normalization (lowercasing the host, collapsing./..segments) can resolve to a different host than an ad hoc pre-parse check matched against, so validate the parsedUri.Host, not the raw input string.
Additional Resources
- Azure Instance Metadata Service - the
Metadata: trueheader requirement and the169.254.169.254endpoint the metadata example blocks - CWE-918 Details
- Microsoft HttpClient Best Practices
- OWASP SSRF Prevention Cheat Sheet
- SocketsHttpHandler.ConnectCallback - the connection hook used above, which
HttpClientHandlerdoes not have