Skip to content
Merged
10 changes: 7 additions & 3 deletions docs/mediator-framework/guide/host-setup-and-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 =>
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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, )"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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, )"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NetEscapades.AspNetCore.SecurityHeaders" />
<PackageReference Include="SimpleInjector" />
<PackageReference Include="SimpleInjector.Integration.AspNetCore.Mvc.Core" />
<ProjectReference Include="..\Ark.Tools.AspNetCore.HealthChecks\Ark.Tools.AspNetCore.HealthChecks.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Provides the optional Ark Minimal API security profile.</summary>
public static class ArkMinimalApiSecurityExtensions
{
/// <summary>
/// Adds the Ark security-header policies used by Minimal API hosts.
/// </summary>
/// <param name="services">The application service collection.</param>
/// <returns>The original service collection.</returns>
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"];
}
Comment thread
AndreaCuneo marked this conversation as resolved.

if (path.StartsWithSegments("/grpc.reflection", StringComparison.OrdinalIgnoreCase))
{
return context.ConfiguredPolicies["GrpcReflection"];
}

return context.DefaultPolicy;
});

return services;
}

/// <summary>
/// Adds the Ark security-header middleware and HSTS middleware to the request pipeline.
/// </summary>
/// <param name="app">The application builder.</param>
/// <returns>The original application builder.</returns>
public static IApplicationBuilder UseArkMinimalApiSecurity(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);

app.UseSecurityHeaders();
app.UseHsts();
return app;
}

private static void ConfigureDocumentationPolicy(HeaderPolicyCollection policy)
Comment thread
AndreaCuneo marked this conversation as resolved.
{
policy
.AddDefaultSecurityHeaders()
.RemoveServerHeader();
policy.Remove("Cross-Origin-Opener-Policy");
policy.AddCrossOriginOpenerPolicy(options => options.UnsafeNone());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, )",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Verifies the optional Ark Minimal API security profile.</summary>
[TestClass]
public sealed class ArkMinimalApiSecurityTests
{
[TestMethod]
public async Task AddsSecurityHeadersAndHsts()
Comment thread
AndreaCuneo marked this conversation as resolved.
{
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");
Comment thread
Copilot marked this conversation as resolved.
}

[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<IHost> CreateHostAsync()
{
var host = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.UseEnvironment(Environments.Production);
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddArkMinimalApiSecurity();
services.Configure<HstsOptions>(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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, )"
}
Expand Down Expand Up @@ -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, )",
Expand Down
Loading