Be careful when manually handling JSON requests in ASP.NET Core

Β· 734 words Β· 4 minutes to read

The other day I was reviewing some code in an ASP.NET Core app. It was an HTTP endpoint, written as a simple, lightweight middleware component (so no MVC), that was handling incoming JSON requests.

The endpoint was intended to act as an ingestion point for larger amounts of data, so by definition it was supposed to perform well. I immediately noticed a few things that raised my eyebrow, that I wanted to share with you today.

Don’t buffer to string, don’t do sync πŸ”—

The middleware code looked, after sort of a strip down for brevity, like this:

app.Use(async (ctx, next) >
{
    try
    {
        string body;
        using (var streamReader = new StreamReader(ctx.Request.Body, Encoding.UTF8))
        {
            body = streamReader.ReadToEnd();
        }
        var json = JObject.Parse(body);
        
        // process JSON
    }
    catch (Exception e)
    {
        // handle exception        
    }
});

This is definitely not the code you’d want to have in production, and most definitely not in a service that is supposed to handle higher loads.

The code buffers the entire body of the request into memory, and it does it synchronously. Since the request body is buffered, we can easily run out of memory if a malicious caller starts hammering us with large requests.

On top of that, the body is being read (buffered) synchronously. This is really a blocking operation, that pauses the thread and holds onto it until the completion. So a number of very slow clients, sending large request payloads, can lead to thread pool starvation. One additional thing worth noting is that Kestrel itself (or more specifically its HttpRequestStream used to represent the request body) is async only. So even when we do the synchronous call to streamReader.ReadToEnd(), Kestrel ends up, internally, going through its async path with GetAwaiter().GetResult().

We can fairly easily rewrite this code to improve on both of the issues we just discussed. To do that in a really simple way, we should use HttpRequestStreamReader from the Microsoft.AspNetCore.WebUtilities package (however, it’s a dependency of Microsoft.AspNetCore package, so chances are you have it already). We’ll also convert the sync code to be async.

b.Use(async (ctx, next) >
{
    try
    {
        using (var streamReader = new HttpRequestStreamReader(request.Body, Encoding.UTF8))
        using (var jsonReader = new JsonTextReader(streamReader))
        {
            var json = await JObject.LoadAsync(jsonReader);       
            // process JSON
        }
    }
    catch (Exception e)
    {
        // handle exception        
    }
});

The code looks very clean (I’d say that perhaps even cleaner than before), and we are no longer pre-buffering the entire JSON into a string. The code is also fully async and can be used in production safely.

Disabling synchronous I/O πŸ”—

Kestrel actually allows us to disable synchronous I/O at the server level. That means that any sync read operation (on the HTTP request) or write operation (on the HTTP response) would throw. This is a great way to ensure that you don’t have any sync operations anywhere.

The flag is called AllowSynchronousIO and is (at the moment, so up to ASP.NET Core 2.2) set to true by default - that’s why we managed to write our crappy version of the code earlier. The flag can be applied at application startup, when configuring your WebHost:

public static async Task Main(string[] args) >
         await WebHost.CreateDefaultBuilder(args)
             .ConfigureKestrel(o > 
             {
                o.AllowSynchronousIO = false;
             })
             .UseStartup<Startup>()
             .Build().RunAsync();

As soon as we have Kestrel set up that way, and we run our original code we should immediately see a runtime InvalidOperationException when attempting to read the request body - with a message “Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead.”

That error obviously doesn’t happen in the new version of our code.

Additional JSON considerations πŸ”—

When handling JSON requests manually, like we did in this post, you may want to observe some additional potential issues.

First of all, if you know upfront the maximum possible payload size that your clients would send, it could be a good idea to restrict accordingly. This is also done via the KestrelOptions:

public static async Task Main(string[] args) >
         await WebHost.CreateDefaultBuilder(args)
             .ConfigureKestrel(o > 
             {
                o.AllowSynchronousIO = false;
                
                // 500 Kb max body size
                o.Limits.MaxRequestBodySize = 500 * 1024; 
             })
             .UseStartup<Startup>()
             .Build().RunAsync();

One additional setting you may want to apply, when deserializing a request into your DTO object (so once you get to deal with JsonSerializerSettings), is to set MaxDepth there. The MVC framework defaults to 32 (so 32 levels deep in the JSON structure) to prevent stack overflow caused by malicious complex JSON requests.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP