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"; +}