Attach the policy to the connection, not the hostname string. Allow only http/https and ports 80/443, reject credentials; resolve the host (or read an IP literal in any notation) and reject loopback, private, link-local and IPv4-mapped addresses; connect to the vetted IP via SocketsHttpHandler.ConnectCallback; disable auto-redirect and re-validate every hop; cap time and bytes.
- Find every server-side fetch built from request data (query, route, body): HttpClient.GetAsync, WebRequest, and EF/Dapper is not involved here.
- Parse the URL once with System.Uri. Enforce a scheme allowlist (http/https) and a port allowlist (80/443) and reject UserInfo.
- Resolve the host with Dns.GetHostAddresses, or recognise an IP literal, and classify each address (System.Net.IPAddress, IPNetwork.Contains); reject loopback, private, link-local, carrier-grade NAT, IPv4-mapped (IsIPv4MappedToIPv6) and NAT64. Reject the whole set if any answer is internal.
- Pin the connection: set SocketsHttpHandler.ConnectCallback to connect to the vetted IP and set request.Headers.Host to the original host, so the name is never re-resolved (no DNS rebinding).
- Set AllowAutoRedirect=false and re-run the policy on each Location with a hop limit; never forward Authorization across origins.
- Set a ConnectTimeout and overall Timeout and read the response with a byte cap; return a generic error to the caller and log the parsed destination server-side.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
// No validation at all: parse the URL and hand the host to the resolver.
private static Transport.UrlParts Target(string rawUrl) => Transport.Parse(rawUrl);With IMPL=vulnerable, 12 of 15 tests fail (10 exploit tests).
The fix: validate the destination
private static readonly HashSet<string> AllowedSchemes = new(StringComparer.Ordinal) { "http", "https" };
private static readonly HashSet<int> AllowedPorts = [80, 443];
// Private, loopback, link-local and other non-public ranges in one table.
private static readonly (byte[] Prefix, int Len)[] DenyV4 = ParseCidrs(
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16",
"198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4");
private sealed record Target(string Scheme, string Ip, int Port, string HostHeader, string Path, string Origin);
// inet_aton-style parse so decimal (2130706433), octal (0177.0.0.1) and hex
// (0x7f.0.0.1) literals are recognised as addresses rather than trusted as names.
private static byte[]? ParseIpLiteral(string host)
{
var inner = host.StartsWith('[') && host.EndsWith(']') ? host[1..^1] : host;
if (inner.Contains(':'))
{
return IPAddress.TryParse(inner, out var v6) && v6.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
? v6.GetAddressBytes()
: null;
}
if (inner != host)
{
return null; // bracketed but not IPv6
}
var parts = host.Split('.');
if (parts.Length is < 1 or > 4)
{
return null;
}
var nums = new long[parts.Length];
for (var i = 0; i < parts.Length; i++)
{
var p = parts[i];
try
{
if (Regex.IsMatch(p, "^0[xX][0-9a-fA-F]+$"))
{
nums[i] = Convert.ToInt64(p[2..], 16);
}
else if (Regex.IsMatch(p, "^0[0-7]*$"))
{
nums[i] = p == "0" ? 0 : Convert.ToInt64(p, 8);
}
else if (Regex.IsMatch(p, "^[1-9][0-9]*$"))
{
nums[i] = long.Parse(p, CultureInfo.InvariantCulture);
}
else
{
return null;
}
}
catch (FormatException)
{
return null;
}
catch (OverflowException)
{
return null;
}
}
long[] caps = [0xFFFFFFFF, 0xFFFFFF, 0xFFFF, 0xFF];
var last = nums.Length - 1;
if (nums[last] > caps[last])
{
return null;
}
for (var i = 0; i < last; i++)
{
if (nums[i] > 0xFF)
{
return null;
}
}
var value = nums.Length switch
{
1 => nums[0],
2 => (nums[0] << 24) | nums[1],
3 => (nums[0] << 24) | (nums[1] << 16) | nums[2],
_ => (nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3],
};
return [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value];
}
private static bool InRange(byte[] addr, byte[] prefix, int prefixLen)
{
for (var bit = 0; bit < prefixLen; bit++)
{
var idx = bit >> 3;
var mask = 1 << (7 - (bit & 7));
if ((addr[idx] & mask) != (prefix[idx] & mask))
{
return false;
}
}
return true;
}
// Reject anything that is not a public address, unwrapping IPv4-mapped IPv6, NAT64
// and 6to4 to the IPv4 they actually carry.
private static void AssertPublic(byte[] original)
{
var addr = original;
if (addr.Length == 16)
{
if (InRange(addr, Bytes("::ffff:0:0"), 96) || InRange(addr, Bytes("64:ff9b::"), 96))
{
addr = addr[12..];
}
else if (InRange(addr, Bytes("2002::"), 16))
{
addr = addr[2..6];
}
else
{
var globalUnicast = InRange(addr, Bytes("2000::"), 3) && !InRange(addr, Bytes("2001:db8::"), 32);
if (!globalUnicast)
{
throw new BlockedRequestException("non-public IPv6 address");
}
return;
}
}
foreach (var (prefix, len) in DenyV4)
{
if (InRange(addr, prefix, len))
{
throw new BlockedRequestException("non-public IPv4 address");
}
}
}
// Parse the URL, enforce the scheme and port allowlists, turn the host into a vetted
// address, and reject (not resolve) IP literals that are not public.
private Target Vet(string rawUrl)
{
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri))
{
throw new BlockedRequestException("invalid URL");
}
if (!AllowedSchemes.Contains(uri.Scheme))
{
throw new BlockedRequestException("scheme not allowed");
}
if (!string.IsNullOrEmpty(uri.UserInfo))
{
throw new BlockedRequestException("credentials in URL not allowed");
}
if (!AllowedPorts.Contains(uri.Port))
{
throw new BlockedRequestException("port not allowed");
}
var literal = ParseIpLiteral(uri.Host);
string[] addresses;
if (literal is not null)
{
AssertPublic(literal);
addresses = [new IPAddress(literal).ToString()];
}
else
{
addresses = [.. _network.Resolve(uri.Host)];
if (addresses.Length == 0)
{
throw new BlockedRequestException("host did not resolve");
}
foreach (var ip in addresses)
{
AssertPublic(IPAddress.Parse(ip.StartsWith('[') ? ip[1..^1] : ip).GetAddressBytes());
}
}
return new Target(uri.Scheme, addresses[0], uri.Port, uri.Authority, uri.PathAndQuery,
uri.GetLeftPart(UriPartial.Authority));
}
private static byte[] Bytes(string ip) => IPAddress.Parse(ip).GetAddressBytes();
private static (byte[], int)[] ParseCidrs(params string[] cidrs)
{
var list = new List<(byte[], int)>();
foreach (var cidr in cidrs)
{
var parts = cidr.Split('/');
list.Add((IPAddress.Parse(parts[0]).GetAddressBytes(), int.Parse(parts[1], CultureInfo.InvariantCulture)));
}
return [.. list];
}With IMPL=fixed, 15 of 15 tests pass.
The fix: re-validate every redirect
// A 3xx response is a fresh destination decision, not permission to follow. Re-run
// the full policy on every hop and pin each connection to its own vetted address.
public async Task<FetchResult> FetchAsync(string url, CancellationToken cancellationToken)
{
var target = url;
for (var hop = 0; hop <= Transport.MaxRedirects; hop++)
{
var t = Vet(target);
var response = await Transport.SendOnceAsync(
_client, t.Scheme, t.Ip, t.Port, t.HostHeader, t.Path, Transport.MaxResponseBytes, cancellationToken);
if (response.Status is >= 300 and < 400 && response.Location is not null)
{
target = new Uri(new Uri(target), response.Location).ToString(); // relative to this hop's URL; validated again
continue;
}
return Transport.Summarise(response);
}
throw new BlockedRequestException("too many redirects");
}With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
// A substring blocklist of "obviously internal" hosts. Looks reassuring; the address
// an attacker actually reaches is decided by DNS, which this never consults.
private static readonly string[] BlockedSubstrings =
["localhost", "127.0.0.1", "0.0.0.0", "169.254", "10.", "192.168", "172.16", "::1", "metadata", "internal"];
private static void Screen(string host)
{
var lower = host.ToLowerInvariant();
foreach (var bad in BlockedSubstrings)
{
if (lower.Contains(bad))
{
throw new BlockedRequestException("host is blocked");
}
}
}
private static Transport.UrlParts Target(string rawUrl) => Transport.Parse(rawUrl);With IMPL=wrong, 4 of 15 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: each payload names an internal destination a different way. The
/// secure outcome is the same every time: 400 and no connection to the internal service.</summary>
public sealed class ExploitTests(FetchApiFactory factory) : FetchApiTests(factory)
{
[Fact]
public Task LoopbackIpv4Literal() => AssertBlocked("http://127.0.0.1/latest/meta-data/");
[Fact]
public Task LoopbackDecimalInteger() => AssertBlocked("http://2130706433/");
[Fact]
public Task LoopbackOctal() => AssertBlocked("http://0177.0.0.1/");
[Fact]
public Task LoopbackHex() => AssertBlocked("http://0x7f.0.0.1/");
[Fact]
public Task Ipv6LoopbackLiteral() => AssertBlocked("http://[::1]/");
[Fact]
public Task Ipv4MappedIpv6Loopback() => AssertBlocked("http://[::ffff:127.0.0.1]/");
[Fact]
public Task CloudMetadataAddressByHostname() => AssertBlocked("http://metadata.internal.example/latest/meta-data/");
[Fact]
public Task PrivateAddressByHostname() => AssertBlocked("http://db.internal.example/");
[Fact]
public Task NameResolvingToLoopback() => AssertBlocked("http://rebind.example/");
[Fact]
public Task RedirectFromAllowedHostToInternal() => AssertBlocked("http://files.partner.example.test/redirect-internal");
}- System.Uri normalises some IPv4 shorthand to dotted-quad, so a string blocklist appears to catch 2130706433 on .NET but still misses a name that resolves internally and a redirect to one.
- IPAddress.Parse('::ffff:127.0.0.1') keeps the address as IPv6 with IsIPv4MappedToIPv6 true; call MapToIPv4 (or unwrap the bytes) before classifying, and handle NAT64 64:ff9b::/96 the same way.
- Dns.GetHostAddresses returns several addresses; vet all of them, not just the first.
- HttpClientHandler/SocketsHttpHandler follow redirects by default (AllowAutoRedirect=true) and re-resolve the host; set it false and re-validate, and connect through ConnectCallback to the vetted IP.
- Setting MaxAutomaticRedirections but leaving AllowAutoRedirect true still follows one hop; disable it entirely for a narrow fetcher.
- A permitted host can still stream forever; cap bytes (read the stream yourself; MaxResponseContentBufferSize only bounds buffered reads) and set timeouts.
- Microsoft.Data.Sqlite-style async caveats do not apply, but the ConnectCallback runs per connection: keep the vetting before it so no unvetted address is ever dialed.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csharp-aspnetcore does this for you).
- Each exploit asserts the request is refused with 400 and the internal test server records zero connection attempts, for 127.0.0.1, decimal/octal/hex, [::1], [::ffff:127.0.0.1], a name resolving internal, and a redirect to an internal host.
- Against your own endpoint, request http://169.254.169.254/latest/meta-data/ and expect a refusal, and confirm a 302 to an internal host is not followed.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | n/a | Semgrep CE has no C# SSRF rule: csharp.lang.security.ssrf.ssrf, csharp.dotnet.security.audit.ssrf.ssrf and csharp.lang.security.injection.tainted-url-host.tainted-url-host all resolve to no rule, and p/security-audit produced no findings on src/. The three-mode regression suite is the enforcement evidence.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | cs/request-forgery | An experimental query: the default and security-extended code-scanning suites exclude experimental/ (misc/suite-helpers), so run it by path. The C# SSRF query tracks remote input into HttpClient/WebRequest sinks. This card drives HttpClient through a SocketsHttpHandler.ConnectCallback and pins the request URI to a vetted IP, so the sink host is a validated address, not the raw request value.Checked: github/codeql main; path corrected 2026-10-11; not executed |
| .NET code analysis | n/aNo built-in CA rule for SSRF | The Roslyn CAxxxx quality/security rules do not cover CWE-918 (SSRF); none applies to this card. Enforcement is the regression suite plus an egress policy.Checked: .NET SDK 10.0.107 |
| Component | Version |
|---|---|
| Language | C# |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| System.Net.Http.SocketsHttpHandler, System.Net.IPAddress (BCL) | .NET 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.2 (Microsoft Testing Platform v2) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/csharp-aspnetcorefolder.
dotnet restore tests/Fetcher.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Fetcher.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Fetcher.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Fetcher.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs ssrf/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0, Apple M5 Max
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- xunitV3 4.0.2
- aspnetMvcTesting 10.0.12
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 12 of 15 tests fail (10 exploit tests); wrong: 4 of 15 tests fail (2 exploit tests)
- Test results
- fixed: 15/15 pass; vulnerable: 12 fail (10 exploit); wrong: 4 fail (2 exploit)
- Notes
- System.Uri normalises decimal/octal/hex IPv4 to dotted-quad, so the wrong implementation's string blocklist catches those forms here and fails only on a name that resolves internally and on a redirect to an internal host (plus the missing scheme and size checks). Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- SocketsHttpHandler.ConnectCallback
- IPAddress.IsIPv4MappedToIPv6 / MapToIPv4
- System.Net.IPNetwork
- HttpClientHandler.AllowAutoRedirect
- CWE-918: Server-Side Request Forgery (SSRF)
- RFC 3927: IPv4 Link-Local Addresses (169.254.0.0/16)
Attack patterns: