From 98f133c581b3ccbcd045cf99495a17074646068e Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:18:07 -0300 Subject: [PATCH 1/4] feat: rate limiting configuration with IP-based partitioning --- .../Extensions/ServiceCollectionExtensions.cs | 58 ++++++++++++++++--- .../Program.cs | 16 ++--- .../Utilities/HttpContextUtilities.cs | 47 +++++++++++++++ 3 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs index 19c05bf..2acbe46 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -14,9 +14,9 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Extensions; /// -/// Extension methods for WebApplicationBuilder to encapsulate service configuration. +/// Extension methods for IServiceCollection to encapsulate service configuration. /// -public static class ServiceCollectionExtensions +public static partial class ServiceCollectionExtensions { /// /// Adds DbContextPool with SQLite configuration for PlayerDbContext. @@ -54,16 +54,25 @@ IWebHostEnvironment environment /// /// /// The IServiceCollection instance. + /// The web host environment. /// The IServiceCollection for method chaining. - public static IServiceCollection AddCorsDefaultPolicy(this IServiceCollection services) + public static IServiceCollection AddCorsDefaultPolicy( + this IServiceCollection services, + IWebHostEnvironment environment + ) { - services.AddCors(options => + if (environment.IsDevelopment()) { - options.AddDefaultPolicy(corsBuilder => + services.AddCors(options => { - corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + options.AddDefaultPolicy(corsBuilder => + { + corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + }); }); - }); + } + + // No CORS configured in Production or other environments return services; } @@ -143,4 +152,39 @@ public static IServiceCollection RegisterPlayerRepository(this IServiceCollectio services.AddScoped(); return services; } + + /// + /// Adds rate limiting configuration with IP-based partitioning. + ///
+ /// + ///
+ /// The IServiceCollection instance. + /// The IServiceCollection for method chaining. + public static IServiceCollection AddFixedWindowRateLimiter(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.GlobalLimiter = PartitionedRateLimiter.Create( + httpContext => + { + var partitionKey = HttpContextUtilities.ExtractIpAddress(httpContext); + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 60, + Window = TimeSpan.FromSeconds(60), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + } + ); + } + ); + + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + }); + + return services; + } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index cd3bdbc..db00be6 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -22,10 +22,11 @@ /* Controllers -------------------------------------------------------------- */ -builder.Services.AddControllers(); -builder.Services.AddCorsDefaultPolicy(); builder.Services.AddHealthChecks(); +builder.Services.AddControllers(); builder.Services.AddValidators(); +builder.Services.AddCorsDefaultPolicy(builder.Environment); +builder.Services.AddFixedWindowRateLimiter(); if (builder.Environment.IsDevelopment()) { @@ -54,17 +55,16 @@ * -------------------------------------------------------------------------- */ app.UseSerilogRequestLogging(); +app.UseHttpsRedirection(); +app.MapHealthChecks("/health"); +app.UseRateLimiter(); +app.MapControllers(); if (app.Environment.IsDevelopment()) { + app.UseCors(); app.UseSwagger(); app.UseSwaggerUI(); } -app.UseHttpsRedirection(); -app.UseCors(); -app.UseRateLimiter(); -app.MapHealthChecks("/health"); -app.MapControllers(); - await app.RunAsync(); diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs new file mode 100644 index 0000000..85aa185 --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs @@ -0,0 +1,47 @@ +using System.Net; + +namespace Dotnet.Samples.AspNetCore.WebApi.Utilities; + +/// +/// Utility class for HTTP context operations. +/// +public static class HttpContextUtilities +{ + /// + /// This method checks for the "X-Forwarded-For" and "X-Real-IP" headers, + /// which are commonly used by proxies to forward the original client IP address. + /// If these headers are not present or the IP address cannot be parsed, + /// it falls back to the remote IP address from the connection. + /// If no valid IP address can be determined, it returns "unknown". + /// + /// The HTTP context. + /// The client IP address or "unknown" if not available. + public static string ExtractIpAddress(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var headers = httpContext.Request.Headers; + IPAddress? ipAddress; + + if (headers.TryGetValue("X-Forwarded-For", out var xForwardedFor)) + { + var clientIp = xForwardedFor + .ToString() + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(clientIp) && IPAddress.TryParse(clientIp, out ipAddress)) + return ipAddress.ToString(); + } + + if ( + headers.TryGetValue("X-Real-IP", out var xRealIp) + && IPAddress.TryParse(xRealIp.ToString(), out ipAddress) + ) + { + return ipAddress.ToString(); + } + + return httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-{Guid.NewGuid()}"; + } +} From e5f80ea24235eec7289f2d28f38ccce282c1726d Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:32:24 -0300 Subject: [PATCH 2/4] chore: adjust runtime and development dependencies --- .../Dotnet.Samples.AspNetCore.WebApi.csproj | 10 +++----- ...net.Samples.AspNetCore.WebApi.Tests.csproj | 24 +++++-------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj index 522de4b..4cfe51f 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj @@ -8,13 +8,8 @@ - - - all - - - all - + + @@ -23,6 +18,7 @@ + diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj index d6c085f..be829ae 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj @@ -9,24 +9,12 @@ - - all - - - all - - - all - - - all - - - all - - - all - + + + + + + From 3e63fa036e10b46369348396338350569ed11765 Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:36:12 -0300 Subject: [PATCH 3/4] chore: extract rate limiting settings to configuration --- .../RateLimiterConfiguration.cs | 25 +++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 20 +++++++++++---- .../Program.cs | 2 +- .../appsettings.Development.json | 5 ++++ .../appsettings.json | 5 ++++ 5 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs new file mode 100644 index 0000000..a4443db --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs @@ -0,0 +1,25 @@ +namespace Dotnet.Samples.AspNetCore.WebApi.Configurations; + +/// +/// Configuration options for the Fixed Window Rate Limiter. +/// +public class RateLimiterConfiguration +{ + /// + /// Gets or sets the maximum number of permits that can be leased per window. + /// Default value is 60 requests. + /// + public int PermitLimit { get; set; } = 60; + + /// + /// Gets or sets the time window in seconds for rate limiting. + /// Default value is 60 seconds (1 minute). + /// + public int WindowSeconds { get; set; } = 60; + + /// + /// Gets or sets the maximum number of requests that can be queued when the permit limit is exceeded. + /// A value of 0 means no queuing (default). + /// + public int QueueLimit { get; set; } = 0; +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs index 2acbe46..59924c9 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -105,7 +105,9 @@ IConfiguration configuration { services.AddSwaggerGen(options => { - options.SwaggerDoc("v1", configuration.GetSection("OpenApiInfo").Get()); + var openApiInfo = configuration.GetSection("OpenApiInfo").Get(); + + options.SwaggerDoc("v1", openApiInfo); options.IncludeXmlComments(SwaggerUtilities.ConfigureXmlCommentsFilePath()); options.AddSecurityDefinition("Bearer", SwaggerUtilities.ConfigureSecurityDefinition()); options.OperationFilter(); @@ -159,9 +161,17 @@ public static IServiceCollection RegisterPlayerRepository(this IServiceCollectio /// /// /// The IServiceCollection instance. + /// The application configuration instance. /// The IServiceCollection for method chaining. - public static IServiceCollection AddFixedWindowRateLimiter(this IServiceCollection services) + public static IServiceCollection AddFixedWindowRateLimiter( + this IServiceCollection services, + IConfiguration configuration + ) { + var settings = + configuration.GetSection("RateLimiter").Get() + ?? new RateLimiterConfiguration(); + services.AddRateLimiter(options => { options.GlobalLimiter = PartitionedRateLimiter.Create( @@ -173,10 +183,10 @@ public static IServiceCollection AddFixedWindowRateLimiter(this IServiceCollecti partitionKey: partitionKey, factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 60, - Window = TimeSpan.FromSeconds(60), + PermitLimit = settings.PermitLimit, + Window = TimeSpan.FromSeconds(settings.WindowSeconds), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0 + QueueLimit = settings.QueueLimit } ); } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index db00be6..4b43640 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -26,7 +26,7 @@ builder.Services.AddControllers(); builder.Services.AddValidators(); builder.Services.AddCorsDefaultPolicy(builder.Environment); -builder.Services.AddFixedWindowRateLimiter(); +builder.Services.AddFixedWindowRateLimiter(builder.Configuration); if (builder.Environment.IsDevelopment()) { diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json index 2e8c6f6..b749dbc 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json +++ b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json @@ -33,5 +33,10 @@ "Name": "MIT License", "Url": "https://opensource.org/license/mit" } + }, + "RateLimiter": { + "PermitLimit": 60, + "WindowSeconds": 60, + "QueueLimit": 0 } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json index 2e8c6f6..b749dbc 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json +++ b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json @@ -33,5 +33,10 @@ "Name": "MIT License", "Url": "https://opensource.org/license/mit" } + }, + "RateLimiter": { + "PermitLimit": 60, + "WindowSeconds": 60, + "QueueLimit": 0 } } From 2e72cdc38a0f33b5468cc398301c2554c144db76 Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:01:14 -0300 Subject: [PATCH 4/4] fix: correctly format fallback IP with Guid in rate limiter partition key --- .../Utilities/HttpContextUtilities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs index 85aa185..e76cfce 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs @@ -42,6 +42,6 @@ public static string ExtractIpAddress(HttpContext httpContext) return ipAddress.ToString(); } - return httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-{Guid.NewGuid()}"; + return httpContext.Connection.RemoteIpAddress?.ToString() ?? $"unknown-{Guid.NewGuid()}"; } }