// ----------------------------------------------------------------------------- // RateLimitingExtensions.cs // Sprint: SPRINT_3500_0002_0003_proof_replay_api // Task: T4 - Rate Limiting // Description: Rate limiting configuration for proof replay endpoints // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; using StellaOps.Scanner.WebService.Security; using System.Threading.RateLimiting; namespace StellaOps.Scanner.WebService.Extensions; /// /// Extensions for configuring rate limiting on proof replay endpoints. /// public static class RateLimitingExtensions { /// /// Policy name for proof replay rate limiting (100 req/hr per tenant). /// public const string ProofReplayPolicy = "ProofReplay"; /// /// Policy name for scan manifest rate limiting (100 req/hr per tenant). /// public const string ManifestPolicy = "Manifest"; /// /// Add rate limiting services for scanner endpoints (proof replay, manifest, etc.). /// public static IServiceCollection AddScannerRateLimiting(this IServiceCollection services) { services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // Proof replay: 100 requests per hour per tenant options.AddPolicy(ProofReplayPolicy, context => { var tenantId = GetTenantId(context); return RateLimitPartition.GetFixedWindowLimiter( partitionKey: $"proof-replay:{tenantId}", factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromHours(1), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0 // No queuing; immediate rejection }); }); // Manifest: 100 requests per hour per tenant options.AddPolicy(ManifestPolicy, context => { var tenantId = GetTenantId(context); return RateLimitPartition.GetFixedWindowLimiter( partitionKey: $"manifest:{tenantId}", factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromHours(1), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0 }); }); // Configure rejection response options.OnRejected = async (context, cancellationToken) => { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.HttpContext.Response.Headers.RetryAfter = "3600"; // 1 hour if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); } await context.HttpContext.Response.WriteAsJsonAsync(new { type = "https://stellaops.org/problems/rate-limit", title = "Too Many Requests", status = 429, detail = "Rate limit exceeded. Please wait before making more requests.", retryAfterSeconds = context.HttpContext.Response.Headers.RetryAfter.ToString() }, cancellationToken); }; }); return services; } /// /// Extract tenant ID from the HTTP context for rate limiting partitioning. /// private static string GetTenantId(HttpContext context) { // Try to get tenant from claims var tenantClaim = context.User?.FindFirst(ScannerClaims.TenantId); if (tenantClaim is not null && !string.IsNullOrWhiteSpace(tenantClaim.Value)) { return tenantClaim.Value; } // Fallback to tenant header if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) && !string.IsNullOrWhiteSpace(headerValue)) { return headerValue.ToString(); } // Fallback to IP address for unauthenticated requests return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; } } /// /// Scanner claims constants. /// public static class ScannerClaims { public const string TenantId = "tenant_id"; }