Skip to content
Katabench
Try free
8 min read The Katabench team

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

server-side request
01

URL parse

scheme, host, port

02

DNS resolve

host to address

03

Address check

reject private / link-local

04

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.

Validating the hostname or a first lookup is not enough. Pin and connect to the address you actually checked, and re-run the whole path on every redirect hop.

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 Found pointing at http://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.254 is also 2852039166 in 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 A record 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.

  1. 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.
  2. Otherwise resolve, validate, and pin the address, as above, so check and connect cannot diverge.
  3. Disable automatic redirects and re-validate each hop.
  4. Restrict schemes and ports. Permit https (and http only if you must), and refuse ports that have no business being fetched.
  5. 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.
  6. 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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.