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()}";
}
}