Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #16

OWASP A10: Mishandling of Exceptional Conditions — Fail Closed and ProblemDetails

Mert Özen Nov 6, 2026 9 min 6 views
OWASP A10: Mishandling of Exceptional Conditions — Fail Closed and ProblemDetails

We start with a permission check that makes everyone an admin when the permission file cannot be read, a review endpoint that sends the SQL error and file path to the client, and a search that keeps running after the client has left. We fix them with fail closed, catching errors where they occur, IExceptionHandler and ProblemDetails, the .NET 10 handler logging change, request timeouts and cancellation.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

In the Bookstore API, who may see the admin reports is kept in a small JSON file. Grace, who sends a request to this endpoint with a customer account, gets 403. One morning the file is deleted by a deploy mistake. What does Grace get when she sends the same request again? Second question: Grace tries to review the same book a second time and the unique index in the database rejects it. What does she see in the response?

The answer to both depends not on how the code behaves when "everything is fine", but on what it does when things go wrong. That moment is exactly the topic of this post.

A10:2025 Mishandling of Exceptional Conditions

A10:2025 Mishandling of Exceptional Conditions is the new category of the 2025 list; it has 24 CWEs. Exceptional conditions do not mean only exceptions: missing or unexpected input, a dependency that does not respond, a full disk and an exhausted connection pool are all in scope. The notable CWEs are an error message containing sensitive information (CWE-209), failing open, that is, staying open when an error happens (CWE-636), and NULL dereference (CWE-476).

OWASP's advice is short: catch the error where it occurs, use a global exception handler as a safety net, roll back incomplete transactions, limit resource consumption, centralize error handling and monitor repeated errors. We touch all of them in the lab in this post. The examples run in the lab on your own machine; only try these techniques on systems you own or have written permission to test.

Fail Open: Leaving the Door Open on Error

Since authentication is not the topic of this post, the signed-in user in the lab is fixed: Grace, a customer. The admin list is read from the permissions.json file. The first version made a well-meant decision so as not to hurt users:

app.MapGet("/admin/reports", (PermissionStore permissions) =>
{
    bool allowed;
    try
    {
        allowed = permissions.LoadAdmins().Contains(Lab.CurrentUser);
    }
    catch (Exception)
    {
        // "Don't lock users out when the permission file has a problem"
        allowed = true;
    }
    return allowed ? Results.Ok(new { revenue = 1250.40m }) : Results.StatusCode(403);
});

With the file in place, everything looks right. I move the file away and send the same request again:

$ curl -i http://localhost:5157/admin/reports
HTTP/1.1 403 Forbidden
...
$ mv permissions.json permissions.json.bak
$ curl -i http://localhost:5157/admin/reports
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...

{"revenue":1250.40}

The moment the permission check broke, everyone became an admin. That is failing open. The catch (Exception) makes two mistakes here: it catches everything without looking at which error came, and it pulls the security decision to its loosest value. On top of that, nothing is logged, so nobody notices. An attacker does not need to break the permission check; waiting for it to break, or finding an input that breaks it, is enough.

Leaking Information Through Exceptions

The review endpoint of the same code catches the error and sends it to the client as it is, "to be helpful":

app.MapPost("/reviews", async (ReviewRequest req, AppDb db) =>
{
    try
    {
        db.Reviews.Add(new Review { BookId = req.BookId, UserEmail = Lab.CurrentUser, Text = req.Text });
        await db.SaveChangesAsync();
        return Results.Ok(new { saved = true });
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.ToString());
    }
});

Sending the second review gives this response in the Production environment (I shortened the 3077-character detail):

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"detail":"Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.\n ---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'UNIQUE constraint failed: Reviews.BookId, Reviews.UserEmail'.\n   at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)\n ... at Program.<>c.<<<Main>$>b__0_3>d.MoveNext() in .../p16/Variants/Vulnerable.cs:line 31 ..."}

This single response tells that the database is SQLite, that EF Core is used, the table and column names, which columns the unique index covers, and the full path of the source file on the server. Even with the developer exception page off in production, code that puts the error into the response by hand creates the same leak. This is the textbook example of CWE-209. For an attacker, each item is a hint that narrows the next step.

Fail Closed and Catching Errors Where They Occur

The fixed version follows two principles: catch the error where it occurs and only with the type you expect; if you cannot make the security decision, deny.

app.MapGet("/admin/reports", (PermissionStore permissions) =>
{
    HashSet<string> admins;
    try
    {
        admins = permissions.LoadAdmins();
    }
    catch (Exception ex) when (ex is IOException or JsonException)
    {
        // No decision means no access: wrap the error in a meaningful type and rethrow
        throw new PermissionStoreUnavailableException("Permission store could not be read.", ex);
    }
    return admins.Contains(Lab.CurrentUser) ? Results.Ok(new { revenue = 1250.40m }) : Results.StatusCode(403);
});

app.MapPost("/reviews", async (ReviewRequest req, AppDb db) =>
{
    db.Reviews.Add(new Review { BookId = req.BookId, UserEmail = Lab.CurrentUser, Text = req.Text });
    try
    {
        await db.SaveChangesAsync();
    }
    catch (DbUpdateException ex) when (ex.InnerException is SqliteException { SqliteExtendedErrorCode: 2067 })
    {
        // 2067 = SQLITE_CONSTRAINT_UNIQUE; other constraint errors are not caught here
        return Results.Problem("You have already reviewed this book.", statusCode: StatusCodes.Status409Conflict);
    }
    return Results.Ok(new { saved = true });
});

The review endpoint catches only the unique constraint, the case it really expects, and returns a meaningful 409 to the user. Since the general SQLite error code 19 covers all constraint errors, checking the extended code 2067 matters; we will shortly see why a NOT NULL error must not fall into this branch. The permission endpoint, on the other hand, does not decide on its own when the file cannot be read; it wraps the error in a meaningful type and throws it upward:

$ mv permissions.json permissions.json.bak
$ curl -i http://localhost:5157/admin/reports
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Service temporarily unavailable.","status":503,"traceId":"00-8a2017000c4889e1d6bff8ae602b30aa-3363a83f256cfbe5-00"}
$ curl -i -X POST http://localhost:5157/reviews -H "Content-Type: application/json" -d '{"bookId":7,"text":"Great again"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"You have already reviewed this book.","traceId":"00-8022f69d48a4b7a3cf33361496b3f5c4-917481748321b80c-00"}

With the file missing, Grace no longer sees reports; 503 is an honest answer, because the problem is not with Grace but with the system. No response contains internal details, only a traceId the support team can search for in the logs.

The Safety Net: UseExceptionHandler, IExceptionHandler and ProblemDetails

The piece that produces the 503 is the central exception handler. The setup is three lines, and the mapping is a small class:

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<PermissionStoreExceptionHandler>();
...
app.UseExceptionHandler();

public sealed class PermissionStoreExceptionHandler(IProblemDetailsService problemDetails,
    ILogger<PermissionStoreExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
    {
        if (exception is not PermissionStoreUnavailableException) return false;

        // .NET 10: the middleware does not log exceptions a handler handles, so we log them
        logger.LogError(exception, "Permission store unavailable, denying {Path}", http.Request.Path);
        http.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            Exception = exception,
            ProblemDetails = { Title = "Service temporarily unavailable.", Status = StatusCodes.Status503ServiceUnavailable }
        });
    }
}

For an exception it does not recognize, TryHandleAsync returns false and the default behavior takes over: a 500 without details, in ProblemDetails format. To see it, I send a review without text in the body; neither the DTO nor the endpoint expects this case:

$ curl -i -X POST http://localhost:5157/reviews -H "Content-Type: application/json" -d '{"bookId":8}'
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-1426148f46095589fad11a21bf18a5f1-ee233327324c94f9-00"}

The details are in the log, not on the client:

fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
      An unhandled exception has occurred while executing the request.
      Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
       ---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'NOT NULL constraint failed: Reviews.Text'.

The error code is 19 again, but the extended code is not 2067; had it fallen into the 409 branch, we would have lied to the user with "you have already reviewed this book". The real fix, of course, is to validate this request on the way in; the global handler is a net for the cases you forgot.

I also saw the .NET 10 change here. When I commented out the LogError line in the handler and repeated the 503 request, there was not a single record in the server log. The middleware no longer writes its own record for exceptions handled by an IExceptionHandler; this behavior can be changed with ExceptionHandlerOptions.SuppressDiagnosticsCallback. So every handler should write its own log, otherwise the alerts you set up in the A09 post never see those errors.

Timeouts, Cancellation and Resource Consumption

In the vulnerable version, the search endpoint waits 10 seconds for a slow dependency and takes no CancellationToken. The client gives up after 3 seconds:

$ date -u +%T; curl -s -m 3 "http://localhost:5157/search?q=tolkien"; echo "curl exit $?"; date -u +%T
19:36:56
curl exit 28
19:36:59

curl's exit code 28 means a timeout. The line in the server log, however, appeared at 19:37:06, 10 seconds after the request started:

info: Program[0]
      Search for tolkien finished

The client left, but the server kept going to the end. For a single request that is harmless; when hundreds of requests hit the same endpoint, threads, connections and the dependency's capacity are spent on work whose answer nobody is waiting for. That is the cheapest form of a resource consumption attack. In the fix, I carry cancellation down the chain and put a time limit on the endpoint:

builder.Services.AddRequestTimeouts();
...
app.UseRequestTimeouts();

app.MapGet("/search", async (string q, ILogger<Program> logger, CancellationToken ct) =>
{
    await Task.Delay(TimeSpan.FromSeconds(10), ct);
    logger.LogInformation("Search for {Query} finished", q);
    return Results.Ok(Array.Empty<string>());
}).WithRequestTimeout(TimeSpan.FromSeconds(2));
$ date -u +%T; curl -s -i -m 5 "http://localhost:5157/search?q=tolkien"; echo "curl exit $?"; date -u +%T
19:36:04
HTTP/1.1 504 Gateway Timeout
Content-Length: 0
...

curl exit 0
19:36:06

It returned 504 in two seconds, the log has a Timeout exception handled. warning and the "finished" line was never written; the work really stopped. The timeout works through the CancellationToken the endpoint receives; if you do not pass the token to database and HttpClient calls, the middleware answers but the work behind it goes on.

The other limits on resource consumption also require knowing their defaults. Printing Kestrel's MaxRequestBodySize in the lab gave 30000000 bytes, about 28.6 MB; that is a lot for an API that takes small JSON, so lower it per endpoint with RequestSizeLimit metadata. To limit the number of requests, use the rate limiting from the A06 post.

Common Pitfalls

The first pitfall is the swallowed error: catch { } or catch (Exception) { return null; }. The error disappears, the caller interprets null as "no record", and the problem shows up somewhere else, in another disguise. If you catch, either fix it, log it and rethrow, or turn it into a meaningful error.

The second is half-finished work. If an order lowers the stock and then fails before the payment record is written, the data stays inconsistent. In EF Core a single SaveChangesAsync is already atomic; if there are several steps, hold the transaction you open with BeginTransactionAsync in an await using, and if it is disposed without CommitAsync it is rolled back.

The third is testing timeouts under a debugger. The request timeouts middleware does not kick in while a debugger is attached; when you try with F5 from Visual Studio and see no timeout, you may think the setting does not work. Run the lab with dotnet run or from the build output.

The fourth is copying catch (Exception) into every endpoint. With a global handler in place, catch in the endpoint only the exceptions you expect and can turn into a meaningful response; leave the rest to the handler.

Checklist

Does every code path that makes a security decision deny when it cannot decide? Do catch blocks catch only expected exception types, and does none of them swallow silently? Is there no stack trace, SQL, table name or file path in any response that goes to the client?

Are AddProblemDetails, UseExceptionHandler and, where needed, IExceptionHandler in place, and does every handler write its own log? Do endpoints that may take long have a timeout, and does the CancellationToken reach every call? Are request body size and request count limited? Are multi-step operations inside a transaction?

A Small Experiment

★☆☆ Instead of deleting permissions.json, write invalid JSON into it. Compare how the vulnerable and the fixed versions behave and find the line where the JsonException is caught.

★★☆ Add a version of the search endpoint that calls a slow local endpoint through HttpClient, and show with logs that the request keeps running in the background after the timeout when you do not pass the token. Then pass the token and observe the difference.

★★★ Remove the LogError line from the handler and use ExceptionHandlerOptions.SuppressDiagnosticsCallback so that the middleware also logs exceptions the handler handles. Compare which approach gives a more useful log record.

With A10 we have covered all ten categories of the 2025 list. In the next post we put them all together: secure defaults for ASP.NET Core and Angular, security testing, and moving from the Top 10 to OWASP ASVS.