Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, DateTime> _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> healthChecksOptions,
IOptions<HealthChecksRateLimitingOptions> healthChecksRateLimitingOptions,
IOptions<HealthChecksBlockingRateLimitingOptions> healthChecksBlockingRateLimitingOptions,
ILogger<HealthChecksRateLimitingMiddleware> 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);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace OrchardCoreContrib.HealthChecks;

public class HealthChecksBlockingRateLimitingOptions : HealthChecksRateLimitingOptions
{
public TimeSpan BlockDuration { get; set; } = TimeSpan.FromMinutes(1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

namespace OrchardCoreContrib.HealthChecks;

public class HealthCheckIPRestrictionMiddleware(
public class HealthChecksIPRestrictionMiddleware(
RequestDelegate next,
IShellConfiguration shellConfiguration,
IOptions<HealthChecksOptions> healthChecksOptions,
ILogger<HealthCheckIPRestrictionMiddleware> logger)
ILogger<HealthChecksIPRestrictionMiddleware> logger)
{
private readonly HealthChecksOptions _healthChecksOptions = healthChecksOptions.Value;
private readonly HashSet<string> _allowedIPs =
Expand Down
Original file line number Diff line number Diff line change
@@ -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> healthChecksOptions,
IOptions<HealthChecksRateLimitingOptions> healthChecksRateLimitingOptions,
ILogger<HealthChecksRateLimitingMiddleware> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}

14 changes: 14 additions & 0 deletions src/OrchardCoreContrib.HealthChecks/Manifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
)]
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

<ItemGroup>
<None Include="../../images/icon.png" Pack="true" PackagePath="icon.png" />
<None Include="README.md" Pack="true" PackagePath="\"/>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
Expand Down
34 changes: 33 additions & 1 deletion src/OrchardCoreContrib.HealthChecks/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthChecksIPRestrictionMiddleware>();
}

[Feature("OrchardCoreContrib.HealthChecks.RateLimiting")]
public class RateLimitingStartup(IShellConfiguration shellConfiguration) : StartupBase
{
public override int Order => 30;

public override void ConfigureServices(IServiceCollection services)
{
app.UseMiddleware<HealthCheckIPRestrictionMiddleware>();
services.Configure<HealthChecksRateLimitingOptions>(shellConfiguration.GetSection($"{Constants.ConfigurationKey}:RateLimiting"));
}

public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
=> app.UseMiddleware<HealthChecksRateLimitingMiddleware>();
}

[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<HealthChecksRateLimitingOptions>(rateLimitingSection);
services.Configure<HealthChecksBlockingRateLimitingOptions>(rateLimitingSection);
}

public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
=> app.UseMiddleware<HealthChecksBlockingRateLimitingMiddleware>();
}

11 changes: 9 additions & 2 deletions src/OrchardCoreContrib.Modules.Web/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

namespace OrchardCoreContrib.HealthChecks.Tests;

public class IPRestrictionTests
[Collection("Sequential")]
public class HealthChecksIPRestrictionTests
{
[Theory]
[InlineData("10.0.0.1", HttpStatusCode.Forbidden)]
Expand All @@ -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);
Expand Down
Loading
Loading