diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4bf9175521..408a8b33e3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/plugins/aspnetcore/plugin.json b/plugins/aspnetcore/plugin.json new file mode 100644 index 0000000000..00c373186d --- /dev/null +++ b/plugins/aspnetcore/plugin.json @@ -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/" +} diff --git a/plugins/aspnetcore/skills/implementing-rate-limiting/SKILL.md b/plugins/aspnetcore/skills/implementing-rate-limiting/SKILL.md new file mode 100644 index 0000000000..5507fe2032 --- /dev/null +++ b/plugins/aspnetcore/skills/implementing-rate-limiting/SKILL.md @@ -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 | + +## 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 | + +**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(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 + +// 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 + { + 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 | +| 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 | diff --git a/tests/aspnetcore/implementing-rate-limiting/eval.yaml b/tests/aspnetcore/implementing-rate-limiting/eval.yaml new file mode 100644 index 0000000000..540ddb0616 --- /dev/null +++ b/tests/aspnetcore/implementing-rate-limiting/eval.yaml @@ -0,0 +1,217 @@ +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" + timeout: 120 + + - 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(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: | + + + net8.0 + + + assertions: + - type: "output_matches" + pattern: "(RejectionStatusCode|429|TooManyRequests)" + rubric: + - "Identified the root cause: default RejectionStatusCode is 503, must explicitly set to 429" + - "Provided code that correctly configures the rate limiter to return HTTP 429 instead of 503" + - "Suggested providing a meaningful response body or headers so clients know when to retry" + timeout: 180 + + - name: "Combine rate limiting with authentication-aware partitioning" + prompt: | + My ASP.NET Core 8 API has two concerns: + 1. Public endpoints (/api/products, /api/search) need per-IP rate limiting at 60 req/min + 2. Authenticated endpoints (/api/orders, /api/account) need per-user rate limiting at 30 req/min + 3. The login endpoint (/api/auth/login) needs brute-force protection — max 5 failed attempts per IP per minute + + How should I configure rate limiting to handle all three cases? Should I use the built-in rate limiting middleware for all of them, or do some need a different approach? + assertions: + - type: "output_matches" + pattern: "(AddRateLimiter|UseRateLimiter)" + - type: "output_matches" + pattern: "(per-user|per.user|ClaimTypes|NameIdentifier|User\\.Find)" + rubric: + - "Configured per-IP rate limiting for public endpoints" + - "Configured per-authenticated-user rate limiting for protected endpoints using claims-based partitioning" + - "Recognized that brute-force login protection has different requirements than API rate limiting" + - "Explained the tradeoffs of using rate limiting middleware vs. Identity lockout or custom logic for login throttling" + timeout: 120 + + - 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 an algorithm that prevents bursting at window boundaries (NOT fixed window)" + - "Explained why fixed window is unsuitable when even distribution is required" + - "Showed a working rate limiter configuration with correct options for the chosen algorithm" + - "Implemented per-merchant partitioning so each merchant has an independent rate limit" + timeout: 120 + + - name: "Diagnose multiple interacting rate limiting bugs" + prompt: | + My ASP.NET Core 8 API has rate limiting configured but it's completely broken. When I test it: + - Named policy endpoints are never rate limited (unlimited requests succeed) + - The global limiter DOES trigger, but returns 503 instead of 429 + - When rate limited, there's no Retry-After header or error body + + Can you look at my Program.cs and tell me everything that'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.GlobalLimiter = PartitionedRateLimiter.Create(context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1) + })); + + options.AddTokenBucketLimiter("api", tokenOptions => + { + tokenOptions.TokenLimit = 20; + tokenOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10); + tokenOptions.TokensPerPeriod = 5; + tokenOptions.AutoReplenishment = false; + tokenOptions.QueueLimit = 0; + }); + }); + + var app = builder.Build(); + app.UseAuthorization(); + app.UseRouting(); + app.UseRateLimiter(); + + app.MapGet("/api/data", () => "Hello!").RequireRateLimiting("api"); + app.MapGet("/api/health", () => "OK"); + app.Run(); + - path: "BrokenRateLimiting.csproj" + content: | + + + net8.0 + + + assertions: + - type: "output_matches" + pattern: "(RejectionStatusCode|429)" + - type: "output_matches" + pattern: "(AutoReplenishment|auto.replenish)" + rubric: + - "Identified that RejectionStatusCode is not set, causing the default 503 response" + - "Found the AutoReplenishment = false bug that causes the token bucket to reject all requests after the initial burst" + - "Identified the middleware ordering issue and explained the correct order" + - "Suggested adding an OnRejected callback for proper error responses with Retry-After" + timeout: 240 + + - name: "Rate limiting behind a reverse proxy" + prompt: | + My ASP.NET Core 8 API uses AddRateLimiter with per-IP partitioning (RemoteIpAddress). It works perfectly in development, but after deploying behind a load balancer, ALL clients share the same rate limit — once the global 100 req/min is hit, every client gets 429 Too Many Requests even though individual clients are well under the limit. How do I fix my per-client rate limiting partitioning? + 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.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetSlidingWindowLimiter(ip, _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 6 + }); + }); + }); + + var app = builder.Build(); + app.UseRouting(); + app.UseRateLimiter(); + app.MapGet("/api/data", () => "Hello!"); + app.Run(); + - path: "ProxyBug.csproj" + content: | + + + net8.0 + + + assertions: + - type: "output_matches" + pattern: "(ForwardedHeaders|KnownProxies|KnownNetworks|UseForwardedHeaders)" + rubric: + - "Identified that behind a reverse proxy, RemoteIpAddress returns the proxy's IP, not the client's" + - "Explained that all requests appear to come from the same partition key, exhausting the shared limit" + - "Provided a concrete solution using ForwardedHeadersMiddleware with KnownProxies or KnownNetworks" + - "Warned about security risks of trusting forwarded headers without restricting to known proxies" + timeout: 180