diff --git a/docs/mediator-framework/guide/host-setup-and-composition.md b/docs/mediator-framework/guide/host-setup-and-composition.md index 0904fece8..6d5358f77 100644 --- a/docs/mediator-framework/guide/host-setup-and-composition.md +++ b/docs/mediator-framework/guide/host-setup-and-composition.md @@ -126,6 +126,7 @@ separate purpose: | --- | --- | --- | | `services.ConfigureAuthentication(configuration)` | Chooses bearer authentication schemes | `AuthenticationEx.cs` | | `services.AddArkMinimalApiHost(container, ...)` | Sets the secure authorization baseline (default and fallback policies) and bridges Microsoft DI and SimpleInjector | `SampleStartup.cs` | +| `services.AddArkMinimalApiSecurity()` | Adds Ark security-header policies for API, documentation and gRPC reflection responses | `SampleStartup.cs` | | `services.AddMessagePackFormatter(...)` | Enables HTTP MessagePack negotiation for contracts that opt in | `SampleStartup.cs` | | `services.ConfigureHttpJsonOptions(...)` | Applies Ark JSON defaults and source-generated metadata | `SampleStartup.cs` | | `services.AddArkProblemDetailsExceptionHandler()` | Maps domain exceptions to RFC 7807 | `SampleStartup.cs` | @@ -143,6 +144,7 @@ services.AddArkMinimalApiHost(container, options => // Invoked after Verify(), while the host starts and before the server accepts requests. options.OnContainerVerified = container => container.StartBus(); }); +services.AddArkMinimalApiSecurity(); services.AddMessagePackFormatter(messagePackResolver); services.ConfigureHttpJsonOptions(options => @@ -166,11 +168,13 @@ Order is observable. The sample uses this sequence: | Order | Middleware | Why | | --- | --- | --- | -| 1 | `UseArkProblemDetailsExceptionHandler()` | Converts unhandled domain exceptions before anything else writes the response | -| 2 | `UseArkMinimalApiHost(container)` | Selects endpoints, builds the caller principal, enforces host-level authorization, and makes the scoped application graph available to handlers | -| 3 | `UseEndpoints(...)` | Maps generated HTTP, gRPC, OpenAPI, and any hand-written endpoints | +| 1 | `UseArkMinimalApiSecurity()` | Applies security headers and HSTS before anything else writes the response | +| 2 | `UseArkProblemDetailsExceptionHandler()` | Converts unhandled domain exceptions | +| 3 | `UseArkMinimalApiHost(container)` | Selects endpoints, builds the caller principal, enforces host-level authorization, and makes the scoped application graph available to handlers | +| 4 | `UseEndpoints(...)` | Maps generated HTTP, gRPC, OpenAPI, and any hand-written endpoints | ```csharp +app.UseArkMinimalApiSecurity(); app.UseArkProblemDetailsExceptionHandler(); app.UseArkMinimalApiHost(container); diff --git a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/SampleStartup.cs b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/SampleStartup.cs index e635e8694..0637f67f7 100644 --- a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/SampleStartup.cs +++ b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/SampleStartup.cs @@ -110,6 +110,7 @@ public void ConfigureServices(IServiceCollection services) // server accepts requests. options.OnContainerVerified = container => container.StartBus(); }); + services.AddArkMinimalApiSecurity(); // The InMemNetwork is registered in Microsoft DI so both the API container and the // processor hosted service can access it without depending on each other. @@ -165,7 +166,10 @@ public void Configure(IApplicationBuilder app) { ArgumentNullException.ThrowIfNull(app); - // Outermost middleware: map unhandled domain exceptions to RFC 7807 ProblemDetails responses. + // Outermost middleware: apply security headers before any response is written. + app.UseArkMinimalApiSecurity(); + + // Map unhandled domain exceptions to RFC 7807 ProblemDetails responses. app.UseArkProblemDetailsExceptionHandler(); app.UseArkMinimalApiHost(_container); diff --git a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/packages.lock.json b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/packages.lock.json index 01f07e6f0..ab1282d37 100644 --- a/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/packages.lock.json +++ b/samples/Ark.MediatorFramework.Sample/src/Ark.MediatorFramework.Sample.WebInterface/packages.lock.json @@ -622,6 +622,11 @@ "resolved": "4.4.0", "contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg==" }, + "NetEscapades.AspNetCore.SecurityHeaders": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "wccVMVSGq9nQ43SjJl1s/VaAo+iWauJYWiZy/Hkvszk2vCQdq7aRn7GL3LTBMsnHuI13JRMfCi9Lk8747+njUg==" + }, "Newtonsoft.Json": { "type": "Transitive", "resolved": "13.0.4", @@ -1037,6 +1042,7 @@ "type": "Project", "dependencies": { "Ark.Tools.AspNetCore.HealthChecks": "[1.0.0, )", + "NetEscapades.AspNetCore.SecurityHeaders": "[1.3.1, )", "SimpleInjector": "[5.6.0, )", "SimpleInjector.Integration.AspNetCore.Mvc.Core": "[5.5.0, )" } diff --git a/samples/Ark.MediatorFramework.Sample/test/Ark.MediatorFramework.Sample.Tests/packages.lock.json b/samples/Ark.MediatorFramework.Sample/test/Ark.MediatorFramework.Sample.Tests/packages.lock.json index 4981ada57..008a924b4 100644 --- a/samples/Ark.MediatorFramework.Sample/test/Ark.MediatorFramework.Sample.Tests/packages.lock.json +++ b/samples/Ark.MediatorFramework.Sample/test/Ark.MediatorFramework.Sample.Tests/packages.lock.json @@ -1390,6 +1390,11 @@ "resolved": "4.4.0", "contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg==" }, + "NetEscapades.AspNetCore.SecurityHeaders": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "wccVMVSGq9nQ43SjJl1s/VaAo+iWauJYWiZy/Hkvszk2vCQdq7aRn7GL3LTBMsnHuI13JRMfCi9Lk8747+njUg==" + }, "Newtonsoft.Json": { "type": "Transitive", "resolved": "13.0.4", @@ -1888,6 +1893,7 @@ "type": "Project", "dependencies": { "Ark.Tools.AspNetCore.HealthChecks": "[1.0.0, )", + "NetEscapades.AspNetCore.SecurityHeaders": "[1.3.1, )", "SimpleInjector": "[5.6.0, )", "SimpleInjector.Integration.AspNetCore.Mvc.Core": "[5.5.0, )" } diff --git a/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/Ark.Tools.AspNetCore.MinimalApi.csproj b/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/Ark.Tools.AspNetCore.MinimalApi.csproj index f5cc69412..3267f5076 100644 --- a/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/Ark.Tools.AspNetCore.MinimalApi.csproj +++ b/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/Ark.Tools.AspNetCore.MinimalApi.csproj @@ -11,6 +11,7 @@ + diff --git a/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/ArkMinimalApiSecurityExtensions.cs b/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/ArkMinimalApiSecurityExtensions.cs new file mode 100644 index 000000000..625980b3c --- /dev/null +++ b/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/ArkMinimalApiSecurityExtensions.cs @@ -0,0 +1,75 @@ +// Copyright (C) 2024 Ark Energy S.r.l. All rights reserved. +// Licensed under the MIT License. See LICENSE file for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Ark.Tools.AspNetCore.MinimalApi; + +/// Provides the optional Ark Minimal API security profile. +public static class ArkMinimalApiSecurityExtensions +{ + /// + /// Adds the Ark security-header policies used by Minimal API hosts. + /// + /// The application service collection. + /// The original service collection. + public static IServiceCollection AddArkMinimalApiSecurity( + this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSecurityHeaderPolicies() + .SetDefaultPolicy(policy => policy + .AddDefaultApiSecurityHeaders() + .RemoveServerHeader()) + .AddPolicy("Scalar", policy => ConfigureDocumentationPolicy(policy)) + .AddPolicy("Swagger", policy => ConfigureDocumentationPolicy(policy)) + .AddPolicy("GrpcReflection", policy => policy + .AddDefaultSecurityHeaders() + .RemoveServerHeader()) + .SetPolicySelector(context => + { + var path = context.HttpContext.Request.Path; + + if (path.StartsWithSegments("/scalar", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/openapi", StringComparison.OrdinalIgnoreCase)) + { + return context.ConfiguredPolicies["Scalar"]; + } + + if (path.StartsWithSegments("/grpc.reflection", StringComparison.OrdinalIgnoreCase)) + { + return context.ConfiguredPolicies["GrpcReflection"]; + } + + return context.DefaultPolicy; + }); + + return services; + } + + /// + /// Adds the Ark security-header middleware and HSTS middleware to the request pipeline. + /// + /// The application builder. + /// The original application builder. + public static IApplicationBuilder UseArkMinimalApiSecurity(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + app.UseSecurityHeaders(); + app.UseHsts(); + return app; + } + + private static void ConfigureDocumentationPolicy(HeaderPolicyCollection policy) + { + policy + .AddDefaultSecurityHeaders() + .RemoveServerHeader(); + policy.Remove("Cross-Origin-Opener-Policy"); + policy.AddCrossOriginOpenerPolicy(options => options.UnsafeNone()); + } +} diff --git a/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/packages.lock.json b/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/packages.lock.json index 9a89952c9..4e5d391b4 100644 --- a/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/packages.lock.json +++ b/src/aspnetcore/Ark.Tools.AspNetCore.MinimalApi/packages.lock.json @@ -49,6 +49,12 @@ "resolved": "18.7.23", "contentHash": "dyj6z8m+LFjpeH69hdnciCcRCmD0YgMlvuQEywiEshjqj/blgQ+PuWX8Vd5gkWQFoBN4e+TFxx6DvGNSv/gIKg==" }, + "NetEscapades.AspNetCore.SecurityHeaders": { + "type": "Direct", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "wccVMVSGq9nQ43SjJl1s/VaAo+iWauJYWiZy/Hkvszk2vCQdq7aRn7GL3LTBMsnHuI13JRMfCi9Lk8747+njUg==" + }, "Polyfill": { "type": "Direct", "requested": "[11.0.1, )", diff --git a/tests/Ark.Tools.AspNetCore.MinimalApi.Tests/ArkMinimalApiSecurityTests.cs b/tests/Ark.Tools.AspNetCore.MinimalApi.Tests/ArkMinimalApiSecurityTests.cs new file mode 100644 index 000000000..db4e4f5c1 --- /dev/null +++ b/tests/Ark.Tools.AspNetCore.MinimalApi.Tests/ArkMinimalApiSecurityTests.cs @@ -0,0 +1,98 @@ +// Copyright (C) 2024 Ark Energy S.r.l. All rights reserved. +// Licensed under the MIT License. See LICENSE file for license information. + +using AwesomeAssertions; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HttpsPolicy; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Ark.Tools.AspNetCore.MinimalApi.Tests; + +/// Verifies the optional Ark Minimal API security profile. +[TestClass] +public sealed class ArkMinimalApiSecurityTests +{ + [TestMethod] + public async Task AddsSecurityHeadersAndHsts() + { + using var host = await CreateHostAsync().ConfigureAwait(false); + using var client = host.GetTestClient(); + client.BaseAddress = new Uri("https://localhost"); + + using var response = await client.GetAsync(new Uri("https://localhost/")).ConfigureAwait(false); + + response.Headers.Server.ToString().Should().BeEmpty(); + response.Headers.Contains("Strict-Transport-Security").Should().BeTrue(); + response.Headers.GetValues("X-Content-Type-Options").Should().ContainSingle("nosniff"); + response.Headers.GetValues("X-Frame-Options").Should().ContainSingle("DENY"); + } + + [TestMethod] + public async Task ApiPolicy_HasSameOriginCrossOriginOpenerPolicy() + { + using var host = await CreateHostAsync().ConfigureAwait(false); + using var client = host.GetTestClient(); + client.BaseAddress = new Uri("https://localhost"); + + using var response = await client.GetAsync(new Uri("https://localhost/")).ConfigureAwait(false); + + response.Headers.GetValues("Cross-Origin-Opener-Policy").Should().ContainSingle("same-origin"); + } + + [TestMethod] + [DataRow("/scalar")] + [DataRow("/scalar/v1")] + [DataRow("/swagger")] + [DataRow("/openapi")] + public async Task DocumentationPolicy_HasUnsafeNoneCrossOriginOpenerPolicy(string path) + { + using var host = await CreateHostAsync().ConfigureAwait(false); + using var client = host.GetTestClient(); + client.BaseAddress = new Uri("https://localhost"); + + using var response = await client.GetAsync(new Uri($"https://localhost{path}")).ConfigureAwait(false); + + response.Headers.GetValues("Cross-Origin-Opener-Policy").Should().ContainSingle("unsafe-none"); + } + + private static async Task CreateHostAsync() + { + var host = new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.UseEnvironment(Environments.Production); + web.ConfigureServices(services => + { + services.AddRouting(); + services.AddArkMinimalApiSecurity(); + services.Configure(o => o.ExcludedHosts.Clear()); + }); + web.Configure(app => + { + app.Use((context, next) => + { + context.Request.Scheme = "https"; + return next(); + }); + app.UseArkMinimalApiSecurity(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/", () => "ok"); + endpoints.MapGet("/scalar/{**path}", () => "scalar ui"); + endpoints.MapGet("/swagger/{**path}", () => "swagger ui"); + endpoints.MapGet("/openapi/{**path}", () => "openapi ui"); + }); + }); + }) + .Build(); + + await host.StartAsync().ConfigureAwait(false); + return host; + } +} diff --git a/tests/Ark.Tools.AspNetCore.MinimalApi.Tests/packages.lock.json b/tests/Ark.Tools.AspNetCore.MinimalApi.Tests/packages.lock.json index be1f67c7d..83dd68c5e 100644 --- a/tests/Ark.Tools.AspNetCore.MinimalApi.Tests/packages.lock.json +++ b/tests/Ark.Tools.AspNetCore.MinimalApi.Tests/packages.lock.json @@ -493,6 +493,7 @@ "type": "Project", "dependencies": { "Ark.Tools.AspNetCore.HealthChecks": "[1.0.0, )", + "NetEscapades.AspNetCore.SecurityHeaders": "[1.3.1, )", "SimpleInjector": "[5.6.0, )", "SimpleInjector.Integration.AspNetCore.Mvc.Core": "[5.5.0, )" } @@ -742,6 +743,12 @@ "resolved": "2.3.2", "contentHash": "FuJgIEfNU+rFHJYGhuqA8uup+3cryJ1N67Tijqo01Jd5xAr28ptz0aXkFfSqK3MhAUmGsrPpgQ92+3+0oXv8AA==" }, + "NetEscapades.AspNetCore.SecurityHeaders": { + "type": "CentralTransitive", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "wccVMVSGq9nQ43SjJl1s/VaAo+iWauJYWiZy/Hkvszk2vCQdq7aRn7GL3LTBMsnHuI13JRMfCi9Lk8747+njUg==" + }, "NodaTime": { "type": "CentralTransitive", "requested": "[3.3.3, )",