Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
/plugins/dotnet-template-engine/ @YuliiaKovalova @JanKrivanek
/tests/dotnet-template-engine/ @YuliiaKovalova @JanKrivanek

# aspnetcore (ASP.NET Core web development)
/plugins/aspnetcore/ @BrennanConroy @adityamandaleeka
/tests/aspnetcore/ @BrennanConroy @adityamandaleeka

# dotnet-nuget (package and dependency management)
/plugins/dotnet-nuget/ @dotnet/area-infrastructure-libraries @kartheekp-ms
/tests/dotnet-nuget/ @dotnet/area-infrastructure-libraries @kartheekp-ms
Expand Down
6 changes: 6 additions & 0 deletions plugins/aspnetcore/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "aspnetcore",
"version": "0.1.0",
"description": "ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns.",
"skills": "./skills/"
Comment on lines +2 to +5

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description/file list mentions plugins/dotnet/... and tests/dotnet/..., but this change introduces a new aspnetcore plugin (plugins/aspnetcore/...) and corresponding tests/aspnetcore/.... Please align the PR description (or the paths) so reviewers and automation know which plugin this skill belongs to.

Copilot uses AI. Check for mistakes.
}
Comment on lines +1 to +6

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description/file list appears out of sync with the actual changes: it mentions plugins/dotnet/... and tests/dotnet/..., but this PR adds the skill under plugins/aspnetcore/... and tests/aspnetcore/.... Please update the PR description to match the new plugin/test paths to avoid confusion for reviewers and tooling.

Copilot uses AI. Check for mistakes.
212 changes: 212 additions & 0 deletions plugins/aspnetcore/skills/implementing-rate-limiting/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
---
name: implementing-rate-limiting
description: >
Implement .NET 7+ built-in rate limiting middleware (AddRateLimiter, UseRateLimiter) with correct
algorithm selection, partitioning, and response handling. USE FOR: adding API rate limiting,
per-client/per-IP/per-user throttling, configuring sliding window or token bucket algorithms,
fixing rate limiter returning 503 instead of 429, fixing silently inactive rate limiting.
DO NOT USE FOR: distributed rate limiting across multiple servers (need Redis-backed solution),
rate limiting at API gateway/reverse proxy layer (YARP, nginx, Azure API Management),
pre-.NET 7 projects (no built-in support).
---

# Implementing Rate Limiting in ASP.NET Core (.NET 7+)

## Inputs

| Input | Required | Description |
|-------|----------|-------------|
| API endpoints to protect | Yes | Which routes need rate limiting |
| Rate limit requirements | Yes | Requests per window, per-client vs global |
| .NET version | No | Must be .NET 7+ for built-in support |
Comment on lines +17 to +21

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tables in this doc use || at the start of each row (e.g., || Input | ...), which doesn’t render as a Markdown table in GitHub. Use single-pipe table syntax (| Input | ...) consistently so the Inputs section renders correctly.

Copilot uses AI. Check for mistakes.

## Workflow

### Step 1: Choose the right algorithm

| Algorithm | Best For | Behavior |
|-----------|----------|----------|
| **Fixed Window** | Simple per-minute/per-hour limits | Counter resets at window boundary. ⚠️ Burst problem: 100 req at end of window + 100 at start of next = 200 in 1 second |
| **Sliding Window** | Smoother rate distribution | Divides window into segments, slides across time. Avoids burst problem |
| **Token Bucket** | Allowing controlled bursts | Tokens replenish at fixed rate, requests consume tokens. Good for APIs that should allow short bursts |
| **Concurrency Limiter** | Limiting simultaneous requests | Caps concurrent in-flight requests, not rate. Good for protecting expensive endpoints |

Comment on lines +27 to +33

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The algorithm comparison table also uses || row prefixes, so it won’t render as a Markdown table. Update it to standard Markdown table formatting (| ... |).

Copilot uses AI. Check for mistakes.
**Common mistake:** Using Fixed Window when you need smooth distribution. A fixed window of "100 per minute" allows 200 requests in 2 seconds if they straddle the window boundary.

### Step 2: Configure the rate limiter in Program.cs

```csharp
using Microsoft.AspNetCore.RateLimiting;
using System.Security.Claims;
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
// CRITICAL: Set rejection status code — default is 503! Most APIs should use 429
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

// Global rate limiter: sliding window (avoids fixed window burst problem)
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
// Use the resolved remote IP. If behind a reverse proxy, configure
// ForwardedHeadersOptions (KnownProxies/KnownNetworks) so RemoteIpAddress
// reflects the real client IP. Do NOT trust X-Forwarded-For directly.
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";

return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: ipAddress,
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6, // 10-second segments for smooth distribution
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0 // Reject immediately, don't queue
});
});

// Named policy: sliding window for sensitive endpoints
options.AddSlidingWindowLimiter("api-sensitive", slidingOptions =>
{
slidingOptions.PermitLimit = 10;
slidingOptions.Window = TimeSpan.FromMinutes(1);
slidingOptions.SegmentsPerWindow = 6; // 10-second segments
slidingOptions.QueueLimit = 0;
});

// Named policy: token bucket for search API (allows bursts)
options.AddTokenBucketLimiter("search", tokenOptions =>
{
tokenOptions.TokenLimit = 20; // Max burst size
tokenOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
tokenOptions.TokensPerPeriod = 5; // 5 tokens every 10 seconds
tokenOptions.QueueLimit = 0;
tokenOptions.AutoReplenishment = true;
});

// Named policy: concurrency limiter for expensive operations
options.AddConcurrencyLimiter("reports", concurrencyOptions =>
{
concurrencyOptions.PermitLimit = 5; // Max 5 simultaneous report generations
concurrencyOptions.QueueLimit = 10;
concurrencyOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});

// Custom response for rejected requests
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers["Retry-After"] =
((int)retryAfter.TotalSeconds).ToString();
}

await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "Too many requests",
retryAfterSeconds = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var r)
? (int)r.TotalSeconds : 60
}, cancellationToken);
};
});
```

### Step 3: CRITICAL — Middleware ordering and application

```csharp
var app = builder.Build();

// Middleware ordering matters:
// UseRouting → UseAuthentication → UseAuthorization → UseRateLimiter
// Placing UseRateLimiter() AFTER UseAuthorization() lets you partition by authenticated user
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter(); // ← AFTER auth so user claims are available for partitioning
Comment on lines +120 to +125

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ordering guidance is incorrect for per-user partitioning. HttpContext.User is populated by UseAuthentication(), so UseRateLimiter() should run after authentication but typically before authorization. Current ASP.NET Core rate limiting samples show UseRouting → UseAuthentication → UseRateLimiter → UseAuthorization.

Suggested change
// UseRouting → UseAuthentication → UseAuthorizationUseRateLimiter
// Placing UseRateLimiter() AFTER UseAuthorization() lets you partition by authenticated user
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter(); // ← AFTER auth so user claims are available for partitioning
// UseRouting → UseAuthentication → UseRateLimiterUseAuthorization
// Placing UseRateLimiter() AFTER authentication (but BEFORE authorization) lets you partition by authenticated user
app.UseRouting();
app.UseAuthentication();
app.UseRateLimiter(); // ← AFTER auth so user claims are available for partitioning
app.UseAuthorization();

Copilot uses AI. Check for mistakes.

// Apply policies to specific endpoints
app.MapGet("/api/search", SearchHandler)
.RequireRateLimiting("search");

app.MapGet("/api/reports", ReportHandler)
.RequireRateLimiting("reports");

// Apply to controller groups
app.MapGroup("/api/admin")
.RequireRateLimiting("api-sensitive")
.MapAdminEndpoints();

// Disable rate limiting for health checks
app.MapHealthChecks("/healthz")
.DisableRateLimiting();

app.MapControllers();
```

**In controllers, use the attribute:**
```csharp
[EnableRateLimiting("api-sensitive")]
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[DisableRateLimiting] // Override for specific action
[HttpGet("status")]
public IActionResult Status() => Ok();
}
```

### Step 4: Per-user/per-tenant partitioning

```csharp
// Per-authenticated-user rate limit
options.AddPolicy("per-user", context =>
{
var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;

return userId is not null
? RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
{
Comment on lines +163 to +169

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-user partitioning snippet references ClaimTypes.NameIdentifier but the snippet doesn’t include using System.Security.Claims; (and other snippets in this file explicitly include needed usings). Add the missing using (or fully-qualify ClaimTypes) so the example compiles as-written.

Copilot uses AI. Check for mistakes.
TokenLimit = 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = 50,
AutoReplenishment = true
})
: RateLimitPartition.GetFixedWindowLimiter("anonymous", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
});
});
```

### Step 5: Common configuration mistakes

| Mistake | Symptom | Fix |
|---------|---------|-----|
| Default `RejectionStatusCode` is 503 | Clients think server is down, not rate limited | Set `options.RejectionStatusCode = 429` |
| `UseRateLimiter()` before `UseRouting()` | Endpoint-specific policies silently don't apply | Move after `UseRouting()` and after `UseAuthorization()` so user claims are available for partitioning |

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row says UseRateLimiter() should be moved after UseAuthorization() so user claims are available. User claims are available after UseAuthentication(), and recommended guidance is to run UseRateLimiter() after routing + authentication, but before authorization, to ensure rate limiting still applies to unauthorized requests when desired.

Suggested change
| `UseRateLimiter()` before `UseRouting()` | Endpoint-specific policies silently don't apply | Move after `UseRouting()` and after `UseAuthorization()` so user claims are available for partitioning |
| `UseRateLimiter()` before `UseRouting()` | Endpoint-specific policies silently don't apply | Move after `UseRouting()` and `UseAuthentication()`, but before `UseAuthorization()`, so user claims are available for partitioning while still rate limiting unauthorized requests |

Copilot uses AI. Check for mistakes.
| Missing `RequireRateLimiting()` on endpoints | Global limiter works but named policies do nothing | Apply policies to endpoints explicitly |
| `AutoReplenishment = false` on token bucket | Tokens never replenish, all requests rejected after initial burst | Set `AutoReplenishment = true` (or use a background timer) |
| `QueueLimit > 0` without timeout | Requests queue indefinitely under sustained overload | Set `QueueLimit = 0` to reject immediately, or impose a timeout |
| Partitioning by `RemoteIpAddress` behind proxy | All requests share one IP (the proxy) | Configure `ForwardedHeadersMiddleware` with `KnownProxies`/`KnownNetworks` so `RemoteIpAddress` reflects the real client IP, or partition by authenticated user |
| Not setting `Retry-After` header | Clients don't know when to retry | Use `OnRejected` callback with `MetadataName.RetryAfter` |

## Validation

- [ ] `RejectionStatusCode` set to 429 (not default 503)
- [ ] `UseRateLimiter()` called after `UseRouting()`
- [ ] Named policies applied to endpoints via `RequireRateLimiting()`
- [ ] Correct algorithm chosen for the use case (sliding window for smooth limits, token bucket for burst tolerance)
- [ ] Partitioning accounts for proxies (`ForwardedHeadersMiddleware` configured so `RemoteIpAddress` is correct)
- [ ] `OnRejected` returns proper error response with `Retry-After` header
- [ ] Health check and monitoring endpoints excluded from rate limiting

## Common Pitfalls

| Pitfall | Impact |
|---------|--------|
| Rate limiter silently inactive | `UseRateLimiter()` in wrong position, no error thrown |
| 503 instead of 429 on rate limit | Default status code misleads clients and monitoring |
| Fixed window burst problem | 2x expected traffic at window boundaries |
| Token bucket never replenishes | `AutoReplenishment = false` rejects everything after burst |
143 changes: 143 additions & 0 deletions tests/aspnetcore/implementing-rate-limiting/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
scenarios:
- name: "Add per-client rate limiting to an ASP.NET Core API"
prompt: |
I need to add rate limiting to my ASP.NET Core 8 API. Requirements:
1. Global limit: 100 requests per minute per IP address
2. Stricter limit on POST /api/orders: 10 requests per minute per authenticated user
3. No limit on GET /healthz
4. Return a proper 429 response with Retry-After header when rate limited

Show me the full Program.cs configuration and how to apply it to the endpoints.
assertions:
- type: "output_matches"
pattern: "(AddRateLimiter|UseRateLimiter)"
- type: "output_matches"
pattern: "(429|TooManyRequests)"
- type: "output_matches"
pattern: "(RequireRateLimiting|EnableRateLimiting)"
- type: "output_matches"
pattern: "DisableRateLimiting"
- type: "output_matches"
pattern: "/healthz"
rubric:
- "Set RejectionStatusCode to 429 (NOT the default 503) — this is critical"
- "Placed UseRateLimiter() AFTER UseRouting() in the middleware pipeline"
- "Used partitioned rate limiter for per-IP global limiting"
- "Applied a named policy to the POST /api/orders endpoint with per-user partitioning"
- "Disabled rate limiting on the healthz endpoint"
- "Configured OnRejected callback that sets Retry-After header"
- "Chose an appropriate algorithm (sliding window or token bucket preferred over fixed window to avoid burst problem)"
timeout: 120

- name: "Rate limiting silently inactive without UseRateLimiter"
prompt: |
I configured rate limiting in my ASP.NET Core 8 API with AddRateLimiter and applied RequireRateLimiting to my endpoints, but requests are never rate limited — even when I send 1000 requests per second, they all succeed. What's wrong?
setup:
files:
- path: "Program.cs"
content: |
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("api", o =>
{
o.PermitLimit = 10;
o.Window = TimeSpan.FromMinutes(1);
});
});

var app = builder.Build();
app.UseRouting();
app.UseAuthorization();
app.MapGet("/api/data", () => "Hello!").RequireRateLimiting("api");
app.Run();
- path: "SilentBug.csproj"
content: |
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
assertions:
- type: "output_matches"
pattern: "(UseRateLimiter|app\\.UseRateLimiter)"
rubric:
- "Identified the root cause: app.UseRateLimiter() is missing from the middleware pipeline"
- "Explained that AddRateLimiter only registers services but UseRateLimiter is required to activate the middleware"
- "Showed the correct middleware ordering: UseRateLimiter() after UseRouting() and UseAuthorization()"

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rubric line encodes the wrong middleware ordering: UseRateLimiter() does not need to be after UseAuthorization() to partition by user; it needs to be after UseAuthentication(). Current guidance/samples use UseRouting → UseAuthentication → UseRateLimiter → UseAuthorization so rate limiting can run before (potentially expensive) authorization.

Suggested change
- "Showed the correct middleware ordering: UseRateLimiter() after UseRouting() and UseAuthorization()"
- "Showed the correct middleware ordering: UseRouting() → UseAuthentication() → UseRateLimiter() → UseAuthorization()"

Copilot uses AI. Check for mistakes.
timeout: 60

- name: "Fix rate limiter returning 503 instead of 429"
prompt: |
My ASP.NET Core API rate limiter is returning 503 Service Unavailable instead of 429 Too Many Requests when clients hit the limit. Clients are confused because 503 usually means the server is down. How do I fix this?
setup:
files:
- path: "Program.cs"
content: |
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1)
}));
});

var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/api/data", () => "Hello!");
app.Run();
- path: "RateLimitBug.csproj"
content: |
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
assertions:
- type: "output_matches"
pattern: "(RejectionStatusCode|429|TooManyRequests)"
- type: "output_matches"
pattern: "(OnRejected|Retry-After|RetryAfter)"
rubric:
- "Identified the root cause: default RejectionStatusCode is 503, must explicitly set to 429"
- "Added RejectionStatusCode = StatusCodes.Status429TooManyRequests"
- "Added OnRejected callback with Retry-After header"
- "Suggested switching from fixed window to sliding window or token bucket to avoid burst-at-boundary"
timeout: 120

- name: "Rate limiting should not apply to authentication scenarios"
prompt: |
I need to implement OAuth token endpoint throttling — limiting failed login attempts to 5 per minute per IP to prevent brute force attacks. Should I use ASP.NET Core rate limiting middleware for this?
assertions:
- type: "output_not_matches"
pattern: "(AddRateLimiter.*GlobalLimiter|UseRateLimiter.*app\\.Map)"
rubric:
Comment on lines +80 to +85

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This output_not_matches pattern is likely too broad and can fail correct answers that merely mention these APIs while explaining what not to do. Consider tightening it to only reject code-like output (e.g., matching builder.Services.AddRateLimiter\s*\() or disallowing affirmative recommendations rather than the presence of AddRateLimiter/UseRateLimiter tokens.

Copilot uses AI. Check for mistakes.
- "Recognized that login throttling has different requirements than API rate limiting"
- "Mentioned that rate limiting middleware works at the HTTP pipeline level, not at the authentication/identity level"
- "Suggested alternatives or complementary approaches (e.g., Identity lockout, custom middleware, or combining rate limiting with auth-aware partitioning)"
- "Did NOT suggest a naive global rate limiter as the complete solution for brute force protection"
timeout: 60

- name: "Choose correct rate limiting algorithm"
prompt: |
I'm building a payment processing API. I need to rate limit to exactly 100 transactions per hour per merchant, with no bursting allowed — each merchant should get a steady, even flow of requests. Which .NET rate limiting algorithm should I use and why?
assertions:
- type: "output_matches"
pattern: "(TokenBucket|token.bucket|SlidingWindow|sliding.window)"
rubric:
- "Recommended token bucket or sliding window (NOT fixed window which allows burst at window boundary)"
- "Explained why fixed window is wrong for this use case (burst problem: 100 requests at end of window + 100 at start of next = 200 in seconds)"

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scenario says “no bursting allowed” and “steady, even flow”, but the rubric explicitly lists token bucket as an acceptable recommendation. Token bucket is typically used specifically to allow bursts unless configured with a very small TokenLimit. Consider updating the rubric wording to prefer sliding window for this scenario (or clarify the token bucket configuration needed to prevent bursts).

Copilot uses AI. Check for mistakes.
- "Showed correct TokenBucketRateLimiterOptions or SlidingWindowRateLimiterOptions configuration"
- "Used per-merchant partitioning (CreatePartitionedLimiter with merchant ID as partition key)"
timeout: 120
Loading