SSRF in ASP.NET Core: your server as the attacker's browser
Server-side request forgery (SSRF) in ASP.NET Core makes your server the attacker's browser. Why naive URL checks fail and how to pin the connected address.
The feature request is always reasonable. Let users add an image by URL. Preview a link. Test a webhook. Import a feed. Each one ends in the same line of code: your server takes a URL from the user and fetches it. And your server sits somewhere the user does not: inside the private network, next to the database, one hop from the cloud metadata endpoint that hands out credentials.
That is server-side request forgery. The user cannot reach http://169.254.169.254/ from their
laptop, but your server can, and you just offered to make requests on their behalf. The input chose
the destination, and the server supplied the network position.
app.MapGet("/proxy", async (string url, IHttpClientFactory factory) =>
{
var client = factory.CreateClient();
var bytes = await client.GetByteArrayAsync(url);
return Results.File(bytes, "application/octet-stream");
});
Four lines, ships constantly, and it will fetch anything: the metadata endpoint, an internal admin console with no auth because it "is not exposed," a database port whose response timing leaks whether it is open. SSRF turns your server into the attacker's browser.
Fetch-a-URL egress path
The check and the connect must agree
URL parse
scheme, host, port
DNS resolve
host to address
Address check
reject private / link-local
Connect
to a chosen address
Where step 3 and step 4 diverge
- DNS rebinding: resolves public for the check, private for the connect
- Redirect to internal: 302 sends the follow-up request to 169.254.169.254
- Alternate encoding: decimal, octal, or IPv6-mapped forms slip a naive filter
Guarded connect
Resolve once, validate the resulting address, then open the socket to that exact address. Nothing can re-resolve between the decision and the connection.
The vulnerability is not the fetch. It is that the address you validated and the address you connect to are allowed to be different. Close that gap and most of SSRF closes with it.
What an attacker reaches through your server
The highest-value target on a cloud host is the instance metadata service at the link-local address
169.254.169.254. On an unprotected instance it returns role credentials, and SSRF is the classic
way to read it from the outside. Newer metadata services default to a session-token scheme (often
called IMDSv2) that requires a PUT to obtain a token before the credential read, which blunts the
simplest GET-only SSRF; treat that as a mitigation that raises the bar, not a reason to skip
validation. Beyond metadata, the same primitive reaches internal services that trust the network,
and it enables port scanning: a fetch that returns quickly, hangs, or errors differently tells the
attacker which internal ports are listening.
Why the obvious checks fail
The first attempt parses the URL and blocks bad-looking hosts. Every layer of that is bypassable if it is the only defense.
- DNS rebinding. You resolve the host, see a public address, approve it, and then the HTTP client
resolves the host again when it connects. Between the two lookups the attacker's DNS server
flips the answer to
127.0.0.1. Your check and your connection saw different addresses. - Redirects. The first URL is a harmless public host that returns
302 Foundpointing athttp://169.254.169.254/. If the client follows redirects automatically, the dangerous request happens after your one check already passed. - Alternate IP encodings.
169.254.169.254is also2852039166in decimal, has octal and hex spellings, and has an IPv6-mapped form. A filter matching the dotted-decimal string misses the rest. - Names that resolve inward. A perfectly ordinary hostname can have an
Arecord pointing at a private or loopback address. The string looks external; the resolution is internal.
The lesson is the one from the path traversal guide: checking the spelling of the input is not the same as checking the thing the input resolves to. There, you normalize the path and prove it stays under the root. Here, you resolve the address and prove you connect to that exact address.
Pin the address you actually connect to
The durable fix removes the gap between check and connect. Resolve DNS yourself, validate the
resulting IPAddress, and open the socket to that address, using
SocketsHttpHandler.ConnectCallback
so the client cannot re-resolve behind your back:
static SocketsHttpHandler CreateGuardedHandler() => new()
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(5),
ConnectCallback = async (context, cancellationToken) =>
{
var addresses = await Dns.GetHostAddressesAsync(
context.DnsEndPoint.Host, cancellationToken);
var target = addresses.FirstOrDefault(ip => !IsBlocked(ip))
?? throw new HttpRequestException("No allowed address for host.");
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(
new IPEndPoint(target, context.DnsEndPoint.Port), cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
};
Because the callback both resolves and connects, there is no second lookup for a rebinding attack to poison. The address you approved is the address the socket opens. The blocklist covers the ranges no outbound fetch feature should ever reach, and normalizes IPv6-mapped addresses so they cannot smuggle a private IPv4 target:
static bool IsBlocked(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();
if (IPAddress.IsLoopback(address))
return true;
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var b = address.GetAddressBytes();
return b[0] == 10 // 10.0.0.0/8, private
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31) // 172.16.0.0/12, private
|| (b[0] == 192 && b[1] == 168) // 192.168.0.0/16, private
|| (b[0] == 169 && b[1] == 254) // link-local, incl. metadata
|| b[0] == 0
|| b[0] >= 224; // multicast and reserved
}
return address.IsIPv6LinkLocal
|| address.IsIPv6UniqueLocal
|| address.IsIPv6Multicast;
}
AllowAutoRedirect = false handles the other bypass. Automatic redirects would follow a 302 into an
internal host without re-running any of this. Turning them off means a redirect is now your decision,
and every hop goes back through the same validated connect:
var current = new Uri(userSuppliedUrl);
for (var hop = 0; hop < 5; hop++)
{
if (current.Scheme != Uri.UriSchemeHttps && current.Scheme != Uri.UriSchemeHttp)
return Results.BadRequest("Unsupported scheme.");
using var response = await client.GetAsync(current, HttpCompletionOption.ResponseHeadersRead);
if (!IsRedirect(response.StatusCode) || response.Headers.Location is null)
return await ReadBoundedBodyAsync(response); // enforce a max byte count here
current = new Uri(current, response.Headers.Location); // next hop re-runs ConnectCallback
}
return Results.BadRequest("Too many redirects.");
Each iteration re-resolves and re-validates through ConnectCallback, so a redirect chain cannot
walk you inward. The loop is bounded, so a redirect cycle cannot hang the request.
Order the defenses by strength
No single check is the answer; they compose, strongest first.
- Allowlist destinations when the feature permits it. A webhook to one partner or an importer for three known providers should talk to a fixed set of hosts. Allowlisting beats every filter because it does not try to enumerate what is dangerous.
- Otherwise resolve, validate, and pin the address, as above, so check and connect cannot diverge.
- Disable automatic redirects and re-validate each hop.
- Restrict schemes and ports. Permit
https(andhttponly if you must), and refuse ports that have no business being fetched. - Enforce timeouts and a response-size cap. Bound how long a fetch runs and how many bytes you read, so a slow or enormous internal response cannot be used as an oracle or a memory bomb.
- Treat egress network policy as the real boundary. Application checks are defense in depth. The control that survives a bug in your code is a network that refuses to route from the web tier to the metadata endpoint and internal subnets at all.
The trade-off is honest: pinning the connect address and banning redirects means some legitimate URLs stop working, like a public host that legitimately redirects through a CDN you did not anticipate. You handle those by widening the allowlist deliberately, not by loosening the address check. A feature that fetches arbitrary URLs is a standing liability, and the safest version of it knows exactly which destinations it is allowed to reach. The OWASP SSRF prevention cheat sheet collects the same layering with more platform detail.
Practice against inputs you would never type
SSRF is awkward to test by hand because the dangerous cases are the spellings you would never think
to try: 0.0.0.0, a 127. address that is not 127.0.0.1, a 10. host, the link-local metadata
range. It is easy to write a check that passes your own inputs and still lets one of those through.
Katabench's Secure Coding exercises include exactly this shape: a
webhook feature whose starter check blocks the literal localhost and nothing else, graded by a
suite that sends loopback, private, and link-local targets and expects every one of them refused
while ordinary public hosts still pass. You write the guard, and the suite tells you which spelling
got past it. The Secure Coding track puts that beside host allowlists, open-redirect
validation, and path traversal so "the input picked a destination" becomes a pattern you catch
before it ships, not after an incident report names it.