Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix unbounded resource consumption in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Bound every resource a request can consume: read the body with a byte cap (413 over it), cap the batch size (400 over it), and add the rate limiter (AddRateLimiter, a fixed window) keyed on the connection address, not a client header. Return 429 with Retry-After. A header-keyed limiter is defeated by rotating the header.

Stack
  • Language C#
  • Runtime ASP.NET Core 10 minimal API on .NET 10.0.12
  • Framework ASP.NET Core 10 (minimal API); Microsoft.AspNetCore.RateLimiting
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks3 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. List what one request can spend: bytes read, items processed, rows returned, time, connections. Set an explicit maximum for each.
  2. Cap the request body: read it with a byte limit and answer 413 over it, or set Kestrel's MaxRequestBodySize. Do not raise the limit globally to silence a 413.
  3. Cap the work: reject a batch or page over N items with 400, and bound any caller-supplied count.
  4. Add the rate limiter with AddRateLimiter and a PartitionedRateLimiter that keys on HttpContext.Connection.RemoteIpAddress (or an authenticated principal), not a request header; set a fixed window, RejectionStatusCode 429, and a Retry-After header in OnRejected. Call app.UseRateLimiter().
  5. Give heavy work a timeout and backpressure, and for several instances use a shared store with the per-instance cap as a floor.
  6. Add regression tests for an over-size body, an over-count batch, and a caller that rotates a request header to escape the limiter.

Vulnerable, fixed and wrong

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

Vulnerablesrc/Quotes/impl/VulnerableLimits.csC#
public long MaxBodyBytes => long.MaxValue;
public int MaxItems => int.MaxValue;
public bool RateLimited => false;
public string PartitionKey(HttpContext context) => "unused";

With IMPL=vulnerable, 4 of 7 tests fail (3 exploit tests).

The fix

Fixedsrc/Quotes/impl/FixedLimits.csC#
public long MaxBodyBytes => 16 * 1024;
public int MaxItems => 100;
public bool RateLimited => true;

// Key on the peer address the server observed, never on anything the client sends.
public string PartitionKey(HttpContext context) =>
    context.Connection.RemoteIpAddress?.ToString() ?? "local";

With IMPL=fixed, 7 of 7 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Quotes/impl/WrongLimits.csC#
public long MaxBodyBytes => 16 * 1024;
public int MaxItems => 100;
public bool RateLimited => true;

// "Give each integration its own quota by its client id." The client sets the header, so it
// also chooses its bucket.
public string PartitionKey(HttpContext context) =>
    context.Request.Headers["X-Client-Id"].ToString() is { Length: > 0 } id
        ? id
        : context.Connection.RemoteIpAddress?.ToString() ?? "local";

With IMPL=wrong, 1 of 7 tests fail (1 exploit test).

Limiter and route: the three limits applied

src/Quotes/Program.csC#
// The body is read with a byte cap (413 over it), the batch size is capped (400 over it), and the
// rate limiter above caps how often a caller may ask.
app.MapPost("/quotes", async (HttpContext context, ILimits cfg) =>
{
    byte[]? body = await ReadCappedAsync(context.Request.Body, cfg.MaxBodyBytes, context.RequestAborted);
    if (body is null)
    {
        return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
    }
    QuotesRequest? request;
    try
    {
        request = JsonSerializer.Deserialize<QuotesRequest>(body, new JsonSerializerOptions(JsonSerializerDefaults.Web));
    }
    catch (JsonException)
    {
        return Results.BadRequest(new { error = "invalid JSON" });
    }
    if (request?.Items is not { } items)
    {
        return Results.BadRequest(new { error = "items must be an array" });
    }
    if (items.Length > cfg.MaxItems)
    {
        return Results.BadRequest(new { error = $"at most {cfg.MaxItems} items per request" });
    }
    return Results.Ok(new { quotes = items.Select(i => new { sku = i.Sku, amount = i.Qty * 100 }) });
});

The regression test

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.

Testtests/Quotes.Tests/ExploitTests.csC#
public sealed class ExploitTests
{
    [Fact]
    public async Task ABodyLargerThanTheCapIsRejectedBeforeItIsProcessed()
    {
        await using var app = new QuotesApiFactory();
        using var client = app.CreateClient();
        // ~57 KiB of JSON, well over a 16 KiB cap.
        var (status, _, body) = await client.PostQuotes(new { items = QuotesClient.Items(3000) });
        Assert.True(status == 413, $"expected 413, got {status}: {body[..Math.Min(80, body.Length)]}");
    }

    [Fact]
    public async Task MoreItemsThanTheCapAreRejected()
    {
        await using var app = new QuotesApiFactory();
        using var client = app.CreateClient();
        // 500 compact items stay under the body cap but over the work cap.
        var (status, _, body) = await client.PostQuotes(new { items = QuotesClient.Items(500) });
        Assert.True(status == 400, $"expected 400, got {status}: {body[..Math.Min(80, body.Length)]}");
    }

    [Fact]
    public async Task RotatingAClientHeaderDoesNotDefeatTheRateLimit()
    {
        await using var app = new QuotesApiFactory();
        using var client = app.CreateClient();
        var statuses = new List<int>();
        for (int i = 0; i < 8; i++)
        {
            var (status, _, _) = await client.PostQuotes(new { items = QuotesClient.Items(1) }, clientId: $"client-{i}");
            statuses.Add(status);
        }
        Assert.True(statuses.Contains(429), $"no request was throttled: {string.Join(",", statuses)}");
    }
}

Pitfalls

  • Keying the rate limiter on a request header (X-Client-Id, an API key the client picks, X-Forwarded-For without a trusted-proxy setup) lets an attacker rotate it into a fresh bucket each time. That is this card's wrong implementation.
  • Relying on Kestrel's MaxRequestBodySize alone: it is not enforced under TestServer and does not apply to every host, so cap the body in code as well when the feature needs a tight bound.
  • Checking items.Length only after deserializing a huge array: cap the body size too, so the array never fully materializes.
  • A per-process limiter does not bound a cluster; use a shared counter and keep the per-instance cap as a floor.
  • Bounding the item count but not the per-item cost: a few items that each trigger an expensive operation can be worse than many cheap ones.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs resource-limits/csharp-aspnetcore does this for you).
  2. Against your own API, send a body over the cap (expect 413), a batch over the item cap (expect 400), and a burst that rotates X-Client-Id (expect a 429 regardless, with Retry-After).
  3. The vulnerable build accepts all three; the wrong build enforces the body and item caps but its header-keyed limiter is defeated by rotation. Do not rely on a scanner: none flags these, so the tests are the control.

Scanner rules

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.

Scanner rules for Fix unbounded resource consumption in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepp/csharp0 findings. No registry rule flags a missing body cap, a missing work cap or a rate limiter keyed on a request header; the three ILimits implementations look the same.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (src only, 0 parse errors)

Static analysis cannot tell a rate limiter keyed on the connection from one keyed on a spoofable header, nor whether a body cap exists. The regression tests, which drive the three limits, are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC#
RuntimeASP.NET Core 10 minimal API on .NET 10.0.12
FrameworkASP.NET Core 10 (minimal API); Microsoft.AspNetCore.RateLimiting
Microsoft.AspNetCore.RateLimiting, System.Threading.RateLimiting (built in).NET 10.0.12
Microsoft.AspNetCore.Mvc.Testing (test only)10.0.12
xunit.v3 (test only)4.0.2

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the resource-limits/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Quotes.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Quotes.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/Quotes.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/Quotes.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs resource-limits/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • aspNetCoreRuntime 10.0.12
  • xunitV3 4.0.2
Matrix
fixed: 7 of 7 tests pass; vulnerable: 4 of 7 tests fail (3 exploit tests); wrong: 1 of 7 tests fail (1 exploit test)
Test results
IMPL=fixed 7/7 pass; IMPL=vulnerable 4/7 fail (3 exploit: the over-size body, the over-count batch and the rotating-header burst, plus the Retry-After test because it never throttles); IMPL=wrong 1/7 fail (1 exploit: a caller rotating X-Client-Id is never throttled; the body and item caps hold). Each test uses its own WebApplicationFactory so the rate-limiter state starts fresh.

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

  1. OWASP Top 10:2025 A06 Insecure Design
  2. OWASP API Security Top 10 2023: API4 Unrestricted Resource Consumption
  3. CWE-770: Allocation of Resources Without Limits or Throttling
  4. CWE-400: Uncontrolled Resource Consumption
  5. Microsoft Learn: Rate limiting middleware in ASP.NET Core (AddRateLimiter, fixed window, OnRejected)
  6. Microsoft Learn: Kestrel MaxRequestBodySize

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.