Skip to content

Commit b8a8ec3

Browse files
committed
Add Health Checks Blocking Rate Limiting feature
1 parent d4c1142 commit b8a8ec3

11 files changed

Lines changed: 202 additions & 19 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.Extensions.Logging;
3+
using Microsoft.Extensions.Options;
4+
using System.Collections.Concurrent;
5+
using System.Threading.RateLimiting;
6+
7+
namespace OrchardCoreContrib.HealthChecks;
8+
9+
public class HealthChecksBlockingRateLimitingMiddleware
10+
{
11+
private static readonly ConcurrentDictionary<string, DateTime> _blockedIPs = new();
12+
13+
private readonly RequestDelegate _next;
14+
private readonly HealthChecksOptions _healthChecksOptions;
15+
private readonly HealthChecksRateLimitingOptions _healthChecksRateLimitingOptions;
16+
private readonly HealthChecksBlockingRateLimitingOptions _healthChecksBlockingRateLimitingOptions;
17+
private readonly SlidingWindowRateLimiter _rateLimiter;
18+
private readonly ILogger _logger;
19+
20+
public HealthChecksBlockingRateLimitingMiddleware(
21+
RequestDelegate next,
22+
IOptions<HealthChecksOptions> healthChecksOptions,
23+
IOptions<HealthChecksRateLimitingOptions> healthChecksRateLimitingOptions,
24+
IOptions<HealthChecksBlockingRateLimitingOptions> healthChecksBlockingRateLimitingOptions,
25+
ILogger<HealthChecksRateLimitingMiddleware> logger)
26+
{
27+
_next = next;
28+
_healthChecksOptions = healthChecksOptions.Value;
29+
_healthChecksRateLimitingOptions = healthChecksRateLimitingOptions.Value;
30+
_healthChecksBlockingRateLimitingOptions = healthChecksBlockingRateLimitingOptions.Value;
31+
_logger = logger;
32+
_rateLimiter = new(new SlidingWindowRateLimiterOptions
33+
{
34+
PermitLimit = _healthChecksRateLimitingOptions.PermitLimit,
35+
Window = _healthChecksRateLimitingOptions.Window,
36+
SegmentsPerWindow = _healthChecksRateLimitingOptions.SegmentsPerWindow,
37+
QueueLimit = _healthChecksRateLimitingOptions.QueueLimit,
38+
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
39+
});
40+
}
41+
42+
public async Task InvokeAsync(HttpContext context)
43+
{
44+
if (context.Request.Path.Equals(_healthChecksOptions.Url))
45+
{
46+
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "Unknown";
47+
48+
if (_blockedIPs.TryGetValue(ip, out var blockedUntil))
49+
{
50+
if (DateTime.UtcNow < blockedUntil)
51+
{
52+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
53+
54+
await context.Response.WriteAsync("Blocked due to excessive requests");
55+
56+
return;
57+
}
58+
else
59+
{
60+
_blockedIPs.TryRemove(ip, out _);
61+
}
62+
}
63+
64+
var rateLimitLease = _rateLimiter.AttemptAcquire(1);
65+
66+
if (!rateLimitLease.IsAcquired)
67+
{
68+
_blockedIPs[ip] = DateTime.UtcNow.Add(_healthChecksBlockingRateLimitingOptions.BlockDuration);
69+
70+
_logger.LogWarning("Rate limit exceeded for IP Address {RemoteIP}.", context.Connection.RemoteIpAddress);
71+
72+
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
73+
74+
await context.Response.WriteAsync("Too Many Requests.");
75+
76+
return;
77+
}
78+
}
79+
80+
await _next(context);
81+
}
82+
}
83+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace OrchardCoreContrib.HealthChecks;
2+
3+
public class HealthChecksBlockingRateLimitingOptions : HealthChecksRateLimitingOptions
4+
{
5+
public TimeSpan BlockDuration { get; set; } = TimeSpan.FromMinutes(1);
6+
}

src/OrchardCoreContrib.HealthChecks/HealthCheckIPRestrictionMiddleware.cs renamed to src/OrchardCoreContrib.HealthChecks/HealthChecksIPRestrictionMiddleware.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66

77
namespace OrchardCoreContrib.HealthChecks;
88

9-
public class HealthCheckIPRestrictionMiddleware(
9+
public class HealthChecksIPRestrictionMiddleware(
1010
RequestDelegate next,
1111
IShellConfiguration shellConfiguration,
1212
IOptions<HealthChecksOptions> healthChecksOptions,
13-
ILogger<HealthCheckIPRestrictionMiddleware> logger)
13+
ILogger<HealthChecksIPRestrictionMiddleware> logger)
1414
{
1515
private readonly HealthChecksOptions _healthChecksOptions = healthChecksOptions.Value;
1616
private readonly HashSet<string> _allowedIPs =

src/OrchardCoreContrib.HealthChecks/HealthChecksRateLimitingMiddleware.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public class HealthChecksRateLimitingMiddleware
99
{
1010
private readonly RequestDelegate _next;
1111
private readonly HealthChecksOptions _healthChecksOptions;
12+
private readonly HealthChecksRateLimitingOptions _healthChecksRateLimitingOptions;
1213
private readonly SlidingWindowRateLimiter _rateLimiter;
1314
private readonly ILogger _logger;
1415

@@ -18,18 +19,18 @@ public HealthChecksRateLimitingMiddleware(
1819
IOptions<HealthChecksRateLimitingOptions> healthChecksRateLimitingOptions,
1920
ILogger<HealthChecksRateLimitingMiddleware> logger)
2021
{
21-
var healthChecksRateLimitingOptionsValue = healthChecksRateLimitingOptions.Value;
22+
_next = next;
23+
_healthChecksOptions = healthChecksOptions.Value;
24+
_healthChecksRateLimitingOptions = healthChecksRateLimitingOptions.Value;
25+
_logger = logger;
2226
_rateLimiter = new(new SlidingWindowRateLimiterOptions
2327
{
24-
PermitLimit = healthChecksRateLimitingOptionsValue.PermitLimit,
25-
Window = healthChecksRateLimitingOptionsValue.Window,
26-
SegmentsPerWindow = healthChecksRateLimitingOptionsValue.SegmentsPerWindow,
27-
QueueLimit = healthChecksRateLimitingOptionsValue.QueueLimit,
28+
PermitLimit = _healthChecksRateLimitingOptions.PermitLimit,
29+
Window = _healthChecksRateLimitingOptions.Window,
30+
SegmentsPerWindow = _healthChecksRateLimitingOptions.SegmentsPerWindow,
31+
QueueLimit = _healthChecksRateLimitingOptions.QueueLimit,
2832
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
2933
});
30-
_next = next;
31-
_healthChecksOptions = healthChecksOptions.Value;
32-
_logger = logger;
3334
}
3435

3536
public async Task InvokeAsync(HttpContext context)

src/OrchardCoreContrib.HealthChecks/Manifest.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@
2424

2525
[assembly: Feature(
2626
Id = "OrchardCoreContrib.HealthCheck.RateLimiting",
27-
Name = "Health Check Rate Limiting",
27+
Name = "Health Checks Rate Limiting",
2828
Description = "Limits requests to health check endpoints to prevent DOS attacks.",
2929
Dependencies = ["OrchardCoreContrib.HealthChecks"]
3030
)]
31+
32+
[assembly: Feature(
33+
Id = "OrchardCore.HealthCheck.BlockingRateLimiting",
34+
Name = "Health Checks Blocking Rate Limiting",
35+
Description = "Adds blocking behavior to the health check rate limiter. Clients exceeding the limit are temporarily blocked to prevent DoS attacks.",
36+
Dependencies = new[] { "OrchardCoreContrib.HealthCheck.RateLimiting" }
37+
)]

src/OrchardCoreContrib.HealthChecks/OrchardCoreContrib.HealthChecks.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
<ItemGroup>
2828
<None Include="../../images/icon.png" Pack="true" PackagePath="icon.png" />
29-
<None Include="README.md" Pack="true" PackagePath="\"/>
29+
<None Include="README.md" Pack="true" PackagePath="\" />
3030
</ItemGroup>
3131

3232
<ItemGroup>

src/OrchardCoreContrib.HealthChecks/Startup.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,30 @@ public class IPRestrictionStartup : StartupBase
7474
public override int Order => 10;
7575

7676
public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
77-
=> app.UseMiddleware<HealthCheckIPRestrictionMiddleware>();
77+
=> app.UseMiddleware<HealthChecksIPRestrictionMiddleware>();
7878
}
7979

8080
[Feature("OrchardCoreContrib.HealthChecks.RateLimiting")]
8181
public class RateLimitingStartup(IShellConfiguration shellConfiguration) : StartupBase
8282
{
83-
public override int Order => 20;
83+
public override int Order => 30;
8484

8585
public override void ConfigureServices(IServiceCollection services)
8686
=> services.Configure<HealthChecksRateLimitingOptions>(shellConfiguration.GetSection($"{Constants.ConfigurationKey}:RateLimiting"));
8787

8888
public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
8989
=> app.UseMiddleware<HealthChecksRateLimitingMiddleware>();
9090
}
91+
92+
[Feature("OrchardCoreContrib.HealthChecks.BlockingRateLimiting")]
93+
public class HealthCheckBlockingRateLimitingStartup(IShellConfiguration shellConfiguration) : StartupBase
94+
{
95+
public override int Order => 20;
96+
97+
public override void ConfigureServices(IServiceCollection services)
98+
=> services.Configure<HealthChecksRateLimitingOptions>(shellConfiguration.GetSection($"{Constants.ConfigurationKey}:BlockingRateLimiting"));
99+
100+
public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
101+
=> app.UseMiddleware<HealthChecksBlockingRateLimitingMiddleware>();
102+
}
103+

src/OrchardCoreContrib.Modules.Web/appsettings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@
5151
"Window": "00:00:10",
5252
"SegmentsPerWindow": 10,
5353
"QueueLimit": 0
54+
},
55+
"BlockingRateLimiting": {
56+
"PermitLimit": 5,
57+
"Window": "00:00:10",
58+
"SegmentsPerWindow": 10,
59+
"QueueLimit": 0,
60+
"BlockDuration": "00:01:00"
5461
}
5562
}
5663
//"OrchardCoreContrib_Garnet": {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using OrchardCoreContrib.HealthChecks.Tests.Tests;
2+
using System.Net;
3+
4+
namespace OrchardCoreContrib.HealthChecks.Tests;
5+
6+
public class HealthCheckBlockingTests
7+
{
8+
[Fact]
9+
public async Task ExceedingLimit_ShouldBlockIP_ForConfiguredDuration()
10+
{
11+
// Arrange
12+
using var context = new SaasSiteContext();
13+
14+
await context.InitializeAsync();
15+
16+
context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "127.0.0.1");
17+
18+
HttpResponseMessage response = null;
19+
20+
// Act
21+
for (int i = 1; i <= 6; i++)
22+
{
23+
response = await context.Client.GetAsync("health");
24+
}
25+
26+
// Assert
27+
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
28+
29+
response = await context.Client.GetAsync("health");
30+
31+
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
32+
33+
var body = await response.Content.ReadAsStringAsync();
34+
35+
Assert.Contains("Blocked due to excessive requests", body);
36+
}
37+
38+
[Fact]
39+
public async Task BlockExpires_AfterDuration_AllowsRequestsAgain()
40+
{
41+
// Arrange
42+
using var context = new SaasSiteContext();
43+
44+
await context.InitializeAsync();
45+
46+
context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "127.0.0.1");
47+
48+
// Act
49+
for (int i = 1; i <= 6; i++)
50+
{
51+
await context.Client.GetAsync("health");
52+
}
53+
54+
var response = await context.Client.GetAsync("health");
55+
56+
// Assert
57+
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
58+
59+
// Wait slightly longer than block duration
60+
await Task.Delay(TimeSpan.FromSeconds(61));
61+
62+
response = await context.Client.GetAsync("health");
63+
64+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
65+
}
66+
}

test/OrchardCoreContrib.HealthChecks.Tests/HealthChecksMiddlewareOrderTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@ public async Task CorrectOrder_BlockedIp_ShouldReturn403_WithoutConsumingLimit()
1616
// Act & Assert
1717
context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "192.168.1.100");
1818

19-
var response = await context.Client.GetAsync("health");
19+
var httpResponse = await context.Client.GetAsync("health");
2020

21-
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
21+
Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode);
2222

2323
context.Client.DefaultRequestHeaders.Remove("X-Forwarded-For");
2424
context.Client.DefaultRequestHeaders.Add("X-Forwarded-For", "127.0.0.1");
2525

2626
for (int i = 1; i <= 5; i++)
2727
{
28-
var okResponse = await context.Client.GetAsync("health");
28+
httpResponse = await context.Client.GetAsync("health");
2929

30-
Assert.Equal(HttpStatusCode.OK, okResponse.StatusCode);
30+
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
3131
}
3232
}
3333
}

0 commit comments

Comments
 (0)