diff --git a/src/OrchardCoreContrib.HealthChecks/HealthChecksBlockingRateLimitingMiddleware.cs b/src/OrchardCoreContrib.HealthChecks/HealthChecksBlockingRateLimitingMiddleware.cs new file mode 100644 index 0000000..c3817dc --- /dev/null +++ b/src/OrchardCoreContrib.HealthChecks/HealthChecksBlockingRateLimitingMiddleware.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; +using System.Threading.RateLimiting; + +namespace OrchardCoreContrib.HealthChecks; + +public class HealthChecksBlockingRateLimitingMiddleware +{ + private static readonly ConcurrentDictionary _blockedIPs = new(); + + private readonly RequestDelegate _next; + private readonly HealthChecksOptions _healthChecksOptions; + private readonly HealthChecksRateLimitingOptions _healthChecksRateLimitingOptions; + private readonly HealthChecksBlockingRateLimitingOptions _healthChecksBlockingRateLimitingOptions; + private readonly SlidingWindowRateLimiter _rateLimiter; + private readonly ILogger _logger; + + public HealthChecksBlockingRateLimitingMiddleware( + RequestDelegate next, + IOptions healthChecksOptions, + IOptions healthChecksRateLimitingOptions, + IOptions healthChecksBlockingRateLimitingOptions, + ILogger logger) + { + _next = next; + _healthChecksOptions = healthChecksOptions.Value; + _healthChecksRateLimitingOptions = healthChecksRateLimitingOptions.Value; + _healthChecksBlockingRateLimitingOptions = healthChecksBlockingRateLimitingOptions.Value; + _logger = logger; + _rateLimiter = new(new SlidingWindowRateLimiterOptions + { + PermitLimit = _healthChecksRateLimitingOptions.PermitLimit, + Window = _healthChecksRateLimitingOptions.Window, + SegmentsPerWindow = _healthChecksRateLimitingOptions.SegmentsPerWindow, + QueueLimit = _healthChecksRateLimitingOptions.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.Equals(_healthChecksOptions.Url)) + { + var ip = context.Connection.RemoteIpAddress?.ToString() ?? "Unknown"; + + if (_blockedIPs.TryGetValue(ip, out var blockedUntil)) + { + if (DateTime.UtcNow < blockedUntil) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + + await context.Response.WriteAsync("Blocked due to excessive requests"); + + return; + } + else + { + _blockedIPs.TryRemove(ip, out _); + } + } + + var rateLimitLease = _rateLimiter.AttemptAcquire(1); + + if (!rateLimitLease.IsAcquired) + { + _blockedIPs[ip] = DateTime.UtcNow.Add(_healthChecksBlockingRateLimitingOptions.BlockDuration); + + _logger.LogWarning("Rate limit exceeded for IP Address {RemoteIP}.", context.Connection.RemoteIpAddress); + + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + + await context.Response.WriteAsync("Too Many Requests."); + + return; + } + } + + await _next(context); + } +} + diff --git a/src/OrchardCoreContrib.HealthChecks/HealthChecksBlockingRateLimitingOptions.cs b/src/OrchardCoreContrib.HealthChecks/HealthChecksBlockingRateLimitingOptions.cs new file mode 100644 index 0000000..1524d12 --- /dev/null +++ b/src/OrchardCoreContrib.HealthChecks/HealthChecksBlockingRateLimitingOptions.cs @@ -0,0 +1,6 @@ +namespace OrchardCoreContrib.HealthChecks; + +public class HealthChecksBlockingRateLimitingOptions : HealthChecksRateLimitingOptions +{ + public TimeSpan BlockDuration { get; set; } = TimeSpan.FromMinutes(1); +} diff --git a/src/OrchardCoreContrib.HealthChecks/HealthCheckIPRestrictionMiddleware.cs b/src/OrchardCoreContrib.HealthChecks/HealthChecksIPRestrictionMiddleware.cs similarity index 92% rename from src/OrchardCoreContrib.HealthChecks/HealthCheckIPRestrictionMiddleware.cs rename to src/OrchardCoreContrib.HealthChecks/HealthChecksIPRestrictionMiddleware.cs index bb9e39b..a10280f 100644 --- a/src/OrchardCoreContrib.HealthChecks/HealthCheckIPRestrictionMiddleware.cs +++ b/src/OrchardCoreContrib.HealthChecks/HealthChecksIPRestrictionMiddleware.cs @@ -6,11 +6,11 @@ namespace OrchardCoreContrib.HealthChecks; -public class HealthCheckIPRestrictionMiddleware( +public class HealthChecksIPRestrictionMiddleware( RequestDelegate next, IShellConfiguration shellConfiguration, IOptions healthChecksOptions, - ILogger logger) + ILogger logger) { private readonly HealthChecksOptions _healthChecksOptions = healthChecksOptions.Value; private readonly HashSet _allowedIPs = diff --git a/src/OrchardCoreContrib.HealthChecks/HealthChecksRateLimitingMiddleware.cs b/src/OrchardCoreContrib.HealthChecks/HealthChecksRateLimitingMiddleware.cs new file mode 100644 index 0000000..f9ebb8c --- /dev/null +++ b/src/OrchardCoreContrib.HealthChecks/HealthChecksRateLimitingMiddleware.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Threading.RateLimiting; + +namespace OrchardCoreContrib.HealthChecks; + +public class HealthChecksRateLimitingMiddleware +{ + private readonly RequestDelegate _next; + private readonly HealthChecksOptions _healthChecksOptions; + private readonly HealthChecksRateLimitingOptions _healthChecksRateLimitingOptions; + private readonly SlidingWindowRateLimiter _rateLimiter; + private readonly ILogger _logger; + + public HealthChecksRateLimitingMiddleware( + RequestDelegate next, + IOptions healthChecksOptions, + IOptions healthChecksRateLimitingOptions, + ILogger logger) + { + _next = next; + _healthChecksOptions = healthChecksOptions.Value; + _healthChecksRateLimitingOptions = healthChecksRateLimitingOptions.Value; + _logger = logger; + _rateLimiter = new(new SlidingWindowRateLimiterOptions + { + PermitLimit = _healthChecksRateLimitingOptions.PermitLimit, + Window = _healthChecksRateLimitingOptions.Window, + SegmentsPerWindow = _healthChecksRateLimitingOptions.SegmentsPerWindow, + QueueLimit = _healthChecksRateLimitingOptions.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.Equals(_healthChecksOptions.Url)) + { + var rateLimitLease = _rateLimiter.AttemptAcquire(1); + + if (!rateLimitLease.IsAcquired) + { + _logger.LogWarning("Rate limit exceeded for IP Address {RemoteIP}.", context.Connection.RemoteIpAddress); + + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + + await context.Response.WriteAsync("Too Many Requests."); + + return; + } + } + + await _next(context); + } +} diff --git a/src/OrchardCoreContrib.HealthChecks/HealthChecksRateLimitingOptions.cs b/src/OrchardCoreContrib.HealthChecks/HealthChecksRateLimitingOptions.cs new file mode 100644 index 0000000..d0ce9bf --- /dev/null +++ b/src/OrchardCoreContrib.HealthChecks/HealthChecksRateLimitingOptions.cs @@ -0,0 +1,13 @@ +namespace OrchardCoreContrib.HealthChecks; + +public class HealthChecksRateLimitingOptions +{ + public int PermitLimit { get; set; } = 5; + + public TimeSpan Window { get; set; } = TimeSpan.FromSeconds(10); + + public int SegmentsPerWindow { get; set; } = 10; + + public int QueueLimit { get; set; } = 0; +} + diff --git a/src/OrchardCoreContrib.HealthChecks/Manifest.cs b/src/OrchardCoreContrib.HealthChecks/Manifest.cs index 681a566..c1e13b0 100644 --- a/src/OrchardCoreContrib.HealthChecks/Manifest.cs +++ b/src/OrchardCoreContrib.HealthChecks/Manifest.cs @@ -21,3 +21,17 @@ Description = "Restricts access to health check endpoints by IP address.", Dependencies = [ "OrchardCoreContrib.HealthChecks" ] )] + +[assembly: Feature( + Id = "OrchardCoreContrib.HealthChecks.RateLimiting", + Name = "Health Checks Rate Limiting", + Description = "Limits requests to health check endpoints to prevent DOS attacks.", + Dependencies = ["OrchardCoreContrib.HealthChecks"] +)] + +[assembly: Feature( + Id = "OrchardCoreContrib.HealthChecks.BlockingRateLimiting", + Name = "Health Checks Blocking Rate Limiting", + Description = "Adds blocking behavior to the health check rate limiter. Clients exceeding the limit are temporarily blocked to prevent DoS attacks.", + Dependencies = new[] { "OrchardCoreContrib.HealthCheck.RateLimiting" } +)] diff --git a/src/OrchardCoreContrib.HealthChecks/OrchardCoreContrib.HealthChecks.csproj b/src/OrchardCoreContrib.HealthChecks/OrchardCoreContrib.HealthChecks.csproj index 7445a8f..7ca1e73 100644 --- a/src/OrchardCoreContrib.HealthChecks/OrchardCoreContrib.HealthChecks.csproj +++ b/src/OrchardCoreContrib.HealthChecks/OrchardCoreContrib.HealthChecks.csproj @@ -26,7 +26,7 @@ - + diff --git a/src/OrchardCoreContrib.HealthChecks/Startup.cs b/src/OrchardCoreContrib.HealthChecks/Startup.cs index 9e14b86..6872829 100644 --- a/src/OrchardCoreContrib.HealthChecks/Startup.cs +++ b/src/OrchardCoreContrib.HealthChecks/Startup.cs @@ -71,8 +71,40 @@ private static async Task WriteResponse(HttpContext context, HealthReport report [Feature("OrchardCoreContrib.HealthChecks.IPRestriction")] public class IPRestrictionStartup : StartupBase { + public override int Order => 10; + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + => app.UseMiddleware(); +} + +[Feature("OrchardCoreContrib.HealthChecks.RateLimiting")] +public class RateLimitingStartup(IShellConfiguration shellConfiguration) : StartupBase +{ + public override int Order => 30; + + public override void ConfigureServices(IServiceCollection services) { - app.UseMiddleware(); + services.Configure(shellConfiguration.GetSection($"{Constants.ConfigurationKey}:RateLimiting")); } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + => app.UseMiddleware(); } + +[Feature("OrchardCoreContrib.HealthChecks.BlockingRateLimiting")] +public class BlockingRateLimitingStartup(IShellConfiguration shellConfiguration) : StartupBase +{ + public override int Order => 20; + + public override void ConfigureServices(IServiceCollection services) + { + var rateLimitingSection = shellConfiguration.GetSection($"{Constants.ConfigurationKey}:RateLimiting"); + + services.Configure(rateLimitingSection); + services.Configure(rateLimitingSection); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + => app.UseMiddleware(); +} + diff --git a/src/OrchardCoreContrib.Modules.Web/appsettings.json b/src/OrchardCoreContrib.Modules.Web/appsettings.json index c264852..f4d05fb 100644 --- a/src/OrchardCoreContrib.Modules.Web/appsettings.json +++ b/src/OrchardCoreContrib.Modules.Web/appsettings.json @@ -45,8 +45,15 @@ "OrchardCoreContrib_HealthChecks": { "Url": "/health", "ShowDetails": true, - "AllowedIPs": [ "127.0.0.1", "::1" ] - }, + "AllowedIPs": [ "127.0.0.1", "::1" ], + "RateLimiting": { + "PermitLimit": 5, + "Window": "00:00:10", + "SegmentsPerWindow": 10, + "QueueLimit": 0, + "BlockDuration": "00:00:10" + } + } //"OrchardCoreContrib_Garnet": { // "Host": "127.0.0.1", // "Port": 3278, diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/HealthCheckBlockingRateLimitingTests.cs b/test/OrchardCoreContrib.HealthChecks.Tests/HealthCheckBlockingRateLimitingTests.cs new file mode 100644 index 0000000..6fd8fbd --- /dev/null +++ b/test/OrchardCoreContrib.HealthChecks.Tests/HealthCheckBlockingRateLimitingTests.cs @@ -0,0 +1,67 @@ +using OrchardCoreContrib.HealthChecks.Tests.Tests; +using System.Net; + +namespace OrchardCoreContrib.HealthChecks.Tests; + +[Collection("Sequential")] +public class HealthCheckBlockingRateLimitingTests +{ + [Fact] + public async Task ExceedingLimit_ShouldBlockIP_ForConfiguredDuration() + { + // Arrange + using var context = new SaasSiteContext(); + + await context.InitializeAsync(); + + context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "127.0.0.1"); + + HttpResponseMessage response = null; + + // Act + for (int i = 1; i <= 6; i++) + { + response = await context.Client.GetAsync("health"); + } + + // Assert + Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode); + + response = await context.Client.GetAsync("health"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.Contains("Blocked due to excessive requests", body); + } + + [Fact] + public async Task BlockExpires_AfterDuration_AllowsRequestsAgain() + { + // Arrange + using var context = new SaasSiteContext(); + + await context.InitializeAsync(); + + context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "127.0.0.1"); + + // Act + for (int i = 1; i <= 6; i++) + { + await context.Client.GetAsync("health"); + } + + var response = await context.Client.GetAsync("health"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + // Wait slightly longer than block duration + await Task.Delay(TimeSpan.FromSeconds(11)); + + response = await context.Client.GetAsync("health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/IPRestrictionTests.cs b/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksIPRestrictionTests.cs similarity index 69% rename from test/OrchardCoreContrib.HealthChecks.Tests/IPRestrictionTests.cs rename to test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksIPRestrictionTests.cs index 73336f3..fbf8f7c 100644 --- a/test/OrchardCoreContrib.HealthChecks.Tests/IPRestrictionTests.cs +++ b/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksIPRestrictionTests.cs @@ -3,7 +3,8 @@ namespace OrchardCoreContrib.HealthChecks.Tests; -public class IPRestrictionTests +[Collection("Sequential")] +public class HealthChecksIPRestrictionTests { [Theory] [InlineData("10.0.0.1", HttpStatusCode.Forbidden)] @@ -16,11 +17,9 @@ public async Task HealthCheck_RestrictIP_IfClientIPNotInAllowedIPs(string client await context.InitializeAsync(); // Act - using var request = new HttpRequestMessage(HttpMethod.Get, "health"); + context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", clientIP); - request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIP); - - var httpResponse = await context.Client.SendAsync(request); + var httpResponse = await context.Client.GetAsync("health"); // Assert Assert.Equal(expectedStatus, httpResponse.StatusCode); diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksMiddlewareOrderTests.cs b/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksMiddlewareOrderTests.cs new file mode 100644 index 0000000..5689ccd --- /dev/null +++ b/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksMiddlewareOrderTests.cs @@ -0,0 +1,102 @@ +using Moq; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCoreContrib.HealthChecks.Tests.Tests; +using System.Net; + +namespace OrchardCoreContrib.HealthChecks.Tests; + +public class HealthChecksMiddlewareOrderTests +{ + [Fact] + public async Task CorrectOrder_BlockedIp_ShouldReturn403_WithoutConsumingLimit() + { + // Arrange + using var context = new SaasSiteContext(); + + await context.InitializeAsync(); + + // Act & Assert + context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "192.168.1.100"); + + var httpResponse = await context.Client.GetAsync("health"); + + Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); + + context.Client.DefaultRequestHeaders.Remove("X-Forwarded-For"); + context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "127.0.0.1"); + + for (int i = 1; i <= 5; i++) + { + httpResponse = await context.Client.GetAsync("health"); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + } + } + + [Fact] + public void BlockingRateLimiting_ShouldRunBefore_RateLimiting() + { + // Arrange + var shellConfiguration = Mock.Of(); + var blockingStartup = new BlockingRateLimitingStartup(shellConfiguration); + var rateLimitingStartup = new RateLimitingStartup(shellConfiguration); + + // Act + var blockingOrder = blockingStartup.Order; + var rateLimitingOrder = rateLimitingStartup.Order; + + // Assert + Assert.True(blockingOrder < rateLimitingOrder, + $"BlockingRateLimiting order ({blockingOrder}) should be less than RateLimiting order ({rateLimitingOrder})."); + } + + //[Fact] + //public async Task BlockingMiddleware_ShouldRunBefore_RateLimitingMiddleware() + //{ + // var executed = new List(); + + // var builder = WebApplication.CreateBuilder(); + // builder.Services.AddOptions() + // .Configure(o => + // { + // o.PermitLimit = 1; + // o.Window = TimeSpan.FromSeconds(5); + // o.SegmentsPerWindow = 5; + // o.BlockDuration = TimeSpan.FromSeconds(10); + // }); + // builder.Services.AddSingleton(); + + // var app = builder.Build(); + + // // Register blocking first + // app.Use(async (ctx, next) => + // { + // executed.Add("Blocking"); + // //var middleware = new HealthChecksBlockingRateLimitingMiddleware(next, + // // ctx.RequestServices.GetRequiredService>(), + // // ctx.RequestServices.GetRequiredService()); + // //await middleware.InvokeAsync(ctx); + // }); + + // // Register plain limiter second + // app.Use(async (ctx, next) => + // { + // executed.Add("RateLimiting"); + // //var middleware = new HealthChecksRateLimitingMiddleware(next, + // // ctx.RequestServices.GetRequiredService()); + // //await middleware.InvokeAsync(ctx); + // }); + + // app.Map("/health", () => Results.Ok("Healthy")); + + // var client = app.CreateClient(); + // var response = await client.GetAsync("/health"); + + // // Assert pipeline order + // Assert.Equal("Blocking", executed[0]); + // Assert.Equal("RateLimiting", executed[1]); + + // // Also assert response is OK for first request + // Assert.Equal(HttpStatusCode.OK, response.StatusCode); + //} +} diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksRateLimitingTests.cs b/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksRateLimitingTests.cs new file mode 100644 index 0000000..becff0d --- /dev/null +++ b/test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksRateLimitingTests.cs @@ -0,0 +1,34 @@ +using OrchardCoreContrib.HealthChecks.Tests.Tests; +using System.Net; + +namespace OrchardCoreContrib.HealthChecks.Tests; + +[Collection("Sequential")] +public class HealthChecksRateLimitingTests +{ + [Fact] + public async Task ExceedingLimit_Returns429() + { + // Arrange + using var context = new SaasSiteContext(); + + await context.InitializeAsync(); + + context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "127.0.0.1"); + + // Act + HttpResponseMessage httpResponse = null; + + for (int i = 1; i <= 6; i++) + { + httpResponse = await context.Client.GetAsync("health"); + } + + // Assert + Assert.Equal(HttpStatusCode.TooManyRequests, httpResponse.StatusCode); + + var body = await httpResponse.Content.ReadAsStringAsync(); + + Assert.Equal("Too Many Requests.", body); + } +} diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreContrib.HealthChecks.Tests.csproj b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreContrib.HealthChecks.Tests.csproj index bc88a2a..417ac2d 100644 --- a/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreContrib.HealthChecks.Tests.csproj +++ b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreContrib.HealthChecks.Tests.csproj @@ -1,26 +1,27 @@  - - enable - false - true - + + enable + false + true + - - - - - - - + + + + + + + + - - - - + + + + - - - + + + diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreStartup.cs b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreStartup.cs index 56f842a..1c9ca89 100644 --- a/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreStartup.cs +++ b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreStartup.cs @@ -17,7 +17,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddOrchardCms(builder => builder .AddSetupFeatures("OrchardCore.Tenants") - .AddTenantFeatures("OrchardCoreContrib.HealthChecks.IPRestriction") + .AddTenantFeatures("OrchardCoreContrib.HealthChecks.IPRestriction", "OrchardCoreContrib.HealthChecks.RateLimiting", "OrchardCoreContrib.HealthChecks.BlockingRateLimiting") .ConfigureServices(serviceCollection => { serviceCollection.AddScoped(sp =>