diff --git a/Directory.Packages.props b/Directory.Packages.props
index 362e62b..1500ed0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -53,6 +53,7 @@
+
diff --git a/OrchardCoreContrib.Modules.sln b/OrchardCoreContrib.Modules.sln
index ca829d5..65a9ac4 100644
--- a/OrchardCoreContrib.Modules.sln
+++ b/OrchardCoreContrib.Modules.sln
@@ -86,6 +86,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.Apis.Sca
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.Contents", "src\OrchardCoreContrib.Contents\OrchardCoreContrib.Contents.csproj", "{46481A89-4274-4126-8E23-FE4F3107AD41}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.HealthChecks.Tests", "test\OrchardCoreContrib.HealthChecks.Tests\OrchardCoreContrib.HealthChecks.Tests.csproj", "{ECFE5B40-2400-4332-9E54-9DBDBC618999}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -228,6 +230,10 @@ Global
{46481A89-4274-4126-8E23-FE4F3107AD41}.Debug|Any CPU.Build.0 = Debug|Any CPU
{46481A89-4274-4126-8E23-FE4F3107AD41}.Release|Any CPU.ActiveCfg = Release|Any CPU
{46481A89-4274-4126-8E23-FE4F3107AD41}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ECFE5B40-2400-4332-9E54-9DBDBC618999}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ECFE5B40-2400-4332-9E54-9DBDBC618999}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ECFE5B40-2400-4332-9E54-9DBDBC618999}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ECFE5B40-2400-4332-9E54-9DBDBC618999}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -267,6 +273,7 @@ Global
{B2029246-BC23-4C18-B71A-55F13CB9E7AB} = {A239BFB0-9BA7-467C-AD41-405D0740633F}
{3D421DD2-F842-4F5A-A95E-5AD8D3632712} = {C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949}
{46481A89-4274-4126-8E23-FE4F3107AD41} = {C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949}
+ {ECFE5B40-2400-4332-9E54-9DBDBC618999} = {A239BFB0-9BA7-467C-AD41-405D0740633F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {48F73B05-7D3D-4ACF-81AE-A98B2B4EFDB2}
diff --git a/src/OrchardCoreContrib.HealthChecks/HealthCheckIPRestrictionMiddleware.cs b/src/OrchardCoreContrib.HealthChecks/HealthCheckIPRestrictionMiddleware.cs
new file mode 100644
index 0000000..bb9e39b
--- /dev/null
+++ b/src/OrchardCoreContrib.HealthChecks/HealthCheckIPRestrictionMiddleware.cs
@@ -0,0 +1,39 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using OrchardCore.Environment.Shell.Configuration;
+
+namespace OrchardCoreContrib.HealthChecks;
+
+public class HealthCheckIPRestrictionMiddleware(
+ RequestDelegate next,
+ IShellConfiguration shellConfiguration,
+ IOptions healthChecksOptions,
+ ILogger logger)
+{
+ private readonly HealthChecksOptions _healthChecksOptions = healthChecksOptions.Value;
+ private readonly HashSet _allowedIPs =
+ shellConfiguration.GetSection($"{Constants.ConfigurationKey}:AllowedIPs").Get()?.ToHashSet(StringComparer.OrdinalIgnoreCase)
+ ?? [];
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (context.Request.Path.Equals(_healthChecksOptions.Url))
+ {
+ var remoteIP = context.Connection.RemoteIpAddress?.ToString();
+ if (!_allowedIPs.Contains(remoteIP))
+ {
+ logger.LogWarning("Unauthorized IP {IP} tried to access {HealthCheckEndpoint}.", remoteIP, _healthChecksOptions.Url);
+
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+
+ await context.Response.WriteAsync("Forbidden");
+
+ return;
+ }
+ }
+
+ await next(context);
+ }
+}
diff --git a/src/OrchardCoreContrib.HealthChecks/Manifest.cs b/src/OrchardCoreContrib.HealthChecks/Manifest.cs
index f2e72c5..681a566 100644
--- a/src/OrchardCoreContrib.HealthChecks/Manifest.cs
+++ b/src/OrchardCoreContrib.HealthChecks/Manifest.cs
@@ -6,6 +6,18 @@
Author = ManifestConstants.Author,
Website = ManifestConstants.Website,
Version = "1.4.0",
- Description = "Provides health checks for the website.",
Category = "Infrastructure"
)]
+
+[assembly: Feature(
+ Id = "OrchardCoreContrib.HealthChecks",
+ Name = "Health Checks",
+ Description = "Provides health checks for the website."
+)]
+
+[assembly: Feature(
+ Id = "OrchardCoreContrib.HealthChecks.IPRestriction",
+ Name = "Health Checks IP Restriction",
+ Description = "Restricts access to health check endpoints by IP address.",
+ Dependencies = [ "OrchardCoreContrib.HealthChecks" ]
+)]
diff --git a/src/OrchardCoreContrib.HealthChecks/Startup.cs b/src/OrchardCoreContrib.HealthChecks/Startup.cs
index cd5b562..9e14b86 100644
--- a/src/OrchardCoreContrib.HealthChecks/Startup.cs
+++ b/src/OrchardCoreContrib.HealthChecks/Startup.cs
@@ -12,6 +12,7 @@
using System.Text.Json;
namespace OrchardCoreContrib.HealthChecks;
+
public class Startup(IShellConfiguration shellConfiguration) : StartupBase
{
private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true };
@@ -66,3 +67,12 @@ private static async Task WriteResponse(HttpContext context, HealthReport report
await context.Response.WriteAsync(JsonSerializer.Serialize(response, response.GetType(), options: _jsonSerializerOptions));
}
}
+
+[Feature("OrchardCoreContrib.HealthChecks.IPRestriction")]
+public class IPRestrictionStartup : StartupBase
+{
+ 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 b3fb89f..c264852 100644
--- a/src/OrchardCoreContrib.Modules.Web/appsettings.json
+++ b/src/OrchardCoreContrib.Modules.Web/appsettings.json
@@ -38,14 +38,15 @@
"RequestUrlPrefix": "blog"
}
]
- }
+ },
//"OrchardCoreContrib_Diagnostics_Elm": {
// "Path": "/elm"
//},
- //"OrchardCoreContrib_HealthChecks": {
- // "Url": "/health",
- // "ShowDetails": true
- //},
+ "OrchardCoreContrib_HealthChecks": {
+ "Url": "/health",
+ "ShowDetails": true,
+ "AllowedIPs": [ "127.0.0.1", "::1" ]
+ },
//"OrchardCoreContrib_Garnet": {
// "Host": "127.0.0.1",
// "Port": 3278,
diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/IPRestrictionTests.cs b/test/OrchardCoreContrib.HealthChecks.Tests/IPRestrictionTests.cs
new file mode 100644
index 0000000..73336f3
--- /dev/null
+++ b/test/OrchardCoreContrib.HealthChecks.Tests/IPRestrictionTests.cs
@@ -0,0 +1,28 @@
+using OrchardCoreContrib.HealthChecks.Tests.Tests;
+using System.Net;
+
+namespace OrchardCoreContrib.HealthChecks.Tests;
+
+public class IPRestrictionTests
+{
+ [Theory]
+ [InlineData("10.0.0.1", HttpStatusCode.Forbidden)]
+ [InlineData("127.0.0.1", HttpStatusCode.OK)]
+ public async Task HealthCheck_RestrictIP_IfClientIPNotInAllowedIPs(string clientIP, HttpStatusCode expectedStatus)
+ {
+ // Arrange
+ using var context = new SaasSiteContext();
+
+ await context.InitializeAsync();
+
+ // Act
+ using var request = new HttpRequestMessage(HttpMethod.Get, "health");
+
+ request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIP);
+
+ var httpResponse = await context.Client.SendAsync(request);
+
+ // Assert
+ Assert.Equal(expectedStatus, httpResponse.StatusCode);
+ }
+}
diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreContrib.HealthChecks.Tests.csproj b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreContrib.HealthChecks.Tests.csproj
new file mode 100644
index 0000000..bc88a2a
--- /dev/null
+++ b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreContrib.HealthChecks.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreStartup.cs b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreStartup.cs
new file mode 100644
index 0000000..56f842a
--- /dev/null
+++ b/test/OrchardCoreContrib.HealthChecks.Tests/OrchardCoreStartup.cs
@@ -0,0 +1,61 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.Modules;
+using OrchardCoreContrib.Modules.Web;
+using OrchardCoreContrib.Testing;
+using OrchardCoreContrib.Testing.Security;
+
+namespace OrchardCoreContrib.HealthChecks.Tests.Tests;
+
+public class OrchardCoreStartup(IConfiguration configuration)
+{
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddOrchardCms(builder => builder
+ .AddSetupFeatures("OrchardCore.Tenants")
+ .AddTenantFeatures("OrchardCoreContrib.HealthChecks.IPRestriction")
+ .ConfigureServices(serviceCollection =>
+ {
+ serviceCollection.AddScoped(sp =>
+ new PermissionContextAuthorizationHandler(sp.GetRequiredService(), SiteContextOptions.PermissionsContexts));
+
+ serviceCollection.AddSingleton(AddHealthChecksConfiguration());
+ })
+ .Configure(appBuilder => appBuilder.UseAuthorization()));
+
+ services.AddSingleton(new ModuleNamesProvider(typeof(Program).Assembly));
+ }
+
+ public void Configure(IApplicationBuilder app)
+ {
+ var forwardedHeadersOptions = new ForwardedHeadersOptions
+ {
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor
+ };
+
+ forwardedHeadersOptions.KnownNetworks.Clear();
+ forwardedHeadersOptions.KnownProxies.Clear();
+
+ app.UseForwardedHeaders(forwardedHeadersOptions);
+ app.UseOrchardCore();
+ }
+
+ private IConfigurationRoot AddHealthChecksConfiguration()
+ {
+ var newConfiguration = new Dictionary
+ {
+ { $"{Constants.ConfigurationKey}:{nameof(HealthChecksOptions.Url)}", "/health" },
+ { $"{Constants.ConfigurationKey}:AllowedIPs:0", "127.0.0.1" },
+ { $"{Constants.ConfigurationKey}:AllowedIPs:1", "::1" }
+ };
+
+ return new ConfigurationBuilder()
+ .AddConfiguration(configuration)
+ .AddInMemoryCollection(newConfiguration)
+ .Build();
+ }
+}
diff --git a/test/OrchardCoreContrib.HealthChecks.Tests/SaasSiteContext.cs b/test/OrchardCoreContrib.HealthChecks.Tests/SaasSiteContext.cs
new file mode 100644
index 0000000..49925ca
--- /dev/null
+++ b/test/OrchardCoreContrib.HealthChecks.Tests/SaasSiteContext.cs
@@ -0,0 +1,8 @@
+using OrchardCoreContrib.Testing;
+
+namespace OrchardCoreContrib.HealthChecks.Tests.Tests;
+
+public class SaasSiteContext : SiteContextBase
+{
+ public SaasSiteContext() => Options.RecipeName = "SaaS";
+}