flawopen.com/ssrf/Csharp
C# SSRF: secure every HttpClient connection
Unchecked URLs let HttpClient reach internal hosts or cloud metadata. Validate each resolved IP at connect time and disable redirects.
Imagine a hotel’s outbound parcel desk with a guarded loading gate. A guest may write any destination, but every courier must pass through the gate: the guard looks up the address, checks every resolved location, and opens the route only to a public one. In .NET, SocketsHttpHandler.ConnectCallback is that gate at the actual socket connection, so DNS rebinding cannot swap in a private address after a separate check. Keep AllowAutoRedirect = false so a public server cannot hand the courier a new internal destination, and UseProxy = false so the handler checks the address it really connects to. IsIPv4MappedToIPv6 also matters because an IPv4 destination can be written in IPv6 clothing.
Core Concepts & Subsystem Terms
HttpClient follows redirects by defaultHttpClientwith the default handler hasAllowAutoRedirect = trueand follows up to 50 redirects (it refuses only HTTPS-to-HTTP). A public URL answeringLocation: http://169.254.169.254/reaches the metadata service after any check on the original URL.Checking the first address with string prefixesDns.GetHostAddressesAsync(host)[0]looks at one address of possibly many, andStartsWith("10.")plusIsLoopbackmisses172.16.0.0/12,192.168.0.0/16,169.254.169.254,100.64.0.0/10and every IPv6 private form.Check-then-fetch and DNS rebinding- The check resolves the hostname once and the handler resolves it again when it connects. A DNS server with a short TTL can give the check a public address and the handler
127.0.0.1(DNS rebinding). SocketsHttpHandler.ConnectCallback- Available since .NET 5,
ConnectCallbackreplaces the handler's own connect step: you receive the host and port, resolve them, reject any non-public address and open the socket to exactly those addresses. It runs for every connection, including redirects if you enable them, and IP-literal hosts are checked too becauseGetHostAddressesAsyncreturns them unchanged. SetUseProxy = falseso a proxy is not what gets checked. IsIPv4MappedToIPv6 and address classificationIsIPv4MappedToIPv6andMapToIPv4()turn::ffff:127.0.0.1back into127.0.0.1;IsIPv6LinkLocal,IsIPv6SiteLocalandIsIPv6UniqueLocal(.NET 6+) cover IPv6, and IPv4 private, link-local, CGNAT and multicast ranges need explicit byte checks.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
A user supplies a URL
A minimal API endpoint binds FetchRequest(Url) and passes Url to GetStringAsync or PostAsJsonAsync.
The service connects to an internal address
A DNS precheck does not pin the socket: the HTTP client resolves the host when it connects, so rebinding can change a public answer to a private destination. SocketsHttpHandler.ConnectCallback can validate and open the socket to the same resolved address.
The internal service answers the trusted caller
HttpClient follows up to 50 redirects by default. A public preview or webhook URL can therefore send the backend to 169.254.169.254 after the original URL was checked.
The attacker reads secrets or pivots
The response path creates the disclosure: an ASP.NET endpoint that returns GetStringAsync output. Return only the data the feature needs, and keep internal credentials and response bodies out of caller-controlled fetch results.
Source Code: Flaw vs. Secure Implementation
// Program.cs: both endpoints let the caller choose the destination
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var app = WebApplication.Create(args);
var http = new HttpClient();
// any URL is fetched, and HttpClient follows redirects
app.MapPost("/preview", async (FetchRequest input) =>
{
var body = await http.GetStringAsync(input.Url);
return Results.Text(body.Length > 5000 ? body[..5000] : body);
});
// one address, three prefixes; the handler resolves again
app.MapPost("/webhooks/test", async (FetchRequest input) =>
{
var address = (await Dns.GetHostAddressesAsync(new Uri(input.Url).Host))[0];
if (IPAddress.IsLoopback(address) || address.ToString().StartsWith("10."))
return Results.BadRequest();
var response = await http.PostAsJsonAsync(input.Url, new { ping = true });
return Results.Ok(new { status = (int)response.StatusCode });
});
app.Run();
record FetchRequest(string Url);
// Program.cs: the handler resolves, checks and connects in one place
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Net.Sockets;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var app = WebApplication.Create(args);
// every connection goes through ConnectCallback; no proxy, no redirects
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseProxy = false,
ConnectTimeout = TimeSpan.FromSeconds(3),
ConnectCallback = async (context, cancellationToken) =>
{
var host = context.DnsEndPoint.Host;
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
if (addresses.Length == 0 || !addresses.All(IsPublic))
throw new HttpRequestException($"blocked destination {host}");
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
},
};
var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10), MaxResponseContentBufferSize = 1_000_000 };
// only http and https URIs reach the guarded client
app.MapPost("/preview", async (FetchRequest input) =>
{
if (CheckedUri(input.Url) is not { } uri)
return Results.BadRequest();
try
{
var body = await http.GetStringAsync(uri);
return Results.Text(body.Length > 5000 ? body[..5000] : body);
}
catch (HttpRequestException)
{
return Results.BadRequest();
}
});
app.MapPost("/webhooks/test", async (FetchRequest input) =>
{
if (CheckedUri(input.Url) is not { } uri)
return Results.BadRequest();
try
{
var response = await http.PostAsJsonAsync(uri, new { ping = true });
return Results.Ok(new { status = (int)response.StatusCode });
}
catch (HttpRequestException)
{
return Results.BadRequest();
}
});
app.Run();
static Uri? CheckedUri(string raw) =>
Uri.TryCreate(raw, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? uri
: null;
// reject loopback, private, link-local, CGNAT and non-global IPv6
static bool IsPublic(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();
if (IPAddress.IsLoopback(address) || address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6UniqueLocal)
return false;
var b = address.GetAddressBytes();
if (b.Length == 16)
return (b[0] & 0xe0) == 0x20;
return !(b[0] == 0 || b[0] == 10 || b[0] == 127 || b[0] >= 224
|| (b[0] == 100 && (b[1] & 0xc0) == 64)
|| (b[0] == 169 && b[1] == 254)
|| (b[0] == 172 && (b[1] & 0xf0) == 16)
|| (b[0] == 192 && b[1] == 168));
}
record FetchRequest(string Url);
Engineering & System Hardening Checklist
- ✓Run CodeQL
cs/request-forgeryin CI and review every outbound call that takes a user-supplied URL. - ✓Use one
HttpClientbuilt on aSocketsHttpHandlerwhoseConnectCallbackresolves the host, rejects every non-public address afterMapToIPv4()and connects only to the checked addresses. - ✓Set
AllowAutoRedirect = falseandUseProxy = false, allow onlyhttpandhttpsURIs, and setConnectTimeout,TimeoutandMaxResponseContentBufferSize. - ✓Enforce IMDSv2 (AWS) or require the
Metadata: trueheader path only from trusted code (Azure), and give the service's managed identity only the permissions it needs. - ✓Restrict egress with network security groups or a firewall that blocks private, link-local and metadata ranges as a second layer.