Feature/gitea deployment workflow #1
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# Code Generation Summary — U3 HTTP Security Headers & CSP
|
||||||
|
|
||||||
|
**Generated**: 2026-07-28
|
||||||
|
**Verified**: `dotnet build SlpModularCms.sln -c Release` → **0 errors**; `dotnet test` → **315 passed, 0 failed** (baseline 253, so **+62**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Core/Hosting/Security/SecurityHeadersOptions.cs` | `SecurityHeadersOptions` + `PathPolicyRule`. Defaults **are** the production values |
|
||||||
|
| `Core/Hosting/Security/CspPolicyCatalog.cs` | `SecurityHeaderSet` record + the two policy definitions as a pure function |
|
||||||
|
| `Core/Hosting/Security/CspPolicyProvider.cs` | `ICspPolicyProvider` + `FrozenDictionary`-backed implementation |
|
||||||
|
| `Core/Hosting/Security/PathPolicyResolver.cs` | Ordered, segment-aware prefix matching |
|
||||||
|
| `Core/Hosting/Security/SecurityHeaderWriter.cs` | **All decision logic**, static and dependency-free |
|
||||||
|
| `Core/Hosting/Security/SecurityHeadersMiddleware.cs` | Glue: gate, resolve, register the response-start callback |
|
||||||
|
| `Core/Hosting/Security/SecurityHeadersExtensions.cs` | `AddCmsSecurityHeaders` / `UseCmsSecurityHeaders` + startup logging |
|
||||||
|
|
||||||
|
## Test Files Created
|
||||||
|
|
||||||
|
| File | Tests | Covers |
|
||||||
|
|---|---|---|
|
||||||
|
| `Core.Tests/Hosting/Security/SecurityHeaderWriterTests.cs` | 20 | Per-header scoping, HSTS gating, never-overwrite, content-type parsing |
|
||||||
|
| `Core.Tests/Hosting/Security/CspPolicyCatalogTests.cs` | 24 | Directive content, `script-src 'self'` under Strict, origin injection, unknown policy |
|
||||||
|
| `Core.Tests/Hosting/Security/PathPolicyResolverTests.cs` | 15 | Segment matching, `/administrator` vs `/admin`, first-match-wins, normalisation |
|
||||||
|
| `Core.Tests/Hosting/Security/SecurityHeadersOptionsValidationTests.cs` | 15 | `ValidateOnStart` through a real container |
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `Api/Program.cs` | `AddCmsSecurityHeaders`; `UseCmsSecurityHeaders` first inside `UseExceptionHandler` |
|
||||||
|
| `Api.Slave/Program.cs` | Same. The slave serves `/api/v1` and `/health` and is reached during diagnosis |
|
||||||
|
| `Api/appsettings.json` | `SecurityHeaders` section — three strict prefixes |
|
||||||
|
| `Api.Slave/appsettings.json` | `SecurityHeaders` section — two strict prefixes (no `/admin` there) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Rule Coverage
|
||||||
|
|
||||||
|
| Rule | Where | Test |
|
||||||
|
|---|---|---|
|
||||||
|
| BR-U3-01 `nosniff` on every response | `SecurityHeaderWriter.Apply` | `Apply_ShouldWriteNosniff_ForAScriptFile` |
|
||||||
|
| BR-U3-02 HSTS except in Development | `sendHsts` parameter | `Apply_ShouldSkipHsts_WhenSendHstsIsFalse` |
|
||||||
|
| BR-U3-03 CSP/Frame/Referrer on HTML only | `IsHtml` early return | `Apply_ShouldWriteOnlyAlwaysHeaders_ForNonHtmlResponses` |
|
||||||
|
| BR-U3-04 never overwrite | `TryAdd` | `Apply_ShouldNotOverwrite_HeadersAlreadyPresent` |
|
||||||
|
| BR-U3-05 write at response start | `Response.OnStarting` | Carried to Build and Test |
|
||||||
|
| BR-U3-06 before static files | `Program.cs` position | Carried to Build and Test |
|
||||||
|
| BR-U3-07 never throw per request | catch-and-log in the callback | Carried to Build and Test |
|
||||||
|
| BR-U3-08 all but HSTS in Development | `sendHsts: false` path | `Apply_ShouldSkipHsts_WhenSendHstsIsFalse` |
|
||||||
|
| BR-U3-09 headers on error responses | inside `UseExceptionHandler` | Carried to Build and Test |
|
||||||
|
| BR-U3-10…12 two policies, config mapping | `CspPolicyCatalog`, `PathPolicyResolver` | Catalog + resolver suites |
|
||||||
|
| BR-U3-13 `script-src 'self'` under Strict | `CspPolicyCatalog.Build` | `Strict_ShouldNotAllowInlineOrEvalScript` |
|
||||||
|
| BR-U3-14 `style-src 'unsafe-inline'` | `CspPolicyCatalog.Build` | `Strict_ShouldAllowInlineStyle_ButOnlyStyle` |
|
||||||
|
| BR-U3-15, BR-U3-16 relaxed is enforcing, no external script | `CspPolicyCatalog.Build` | `Relaxed_ShouldNotPermitExternalScriptOrigins_ByDefault` |
|
||||||
|
| BR-U3-17 `object-src`/`base-uri` | `CspPolicyCatalog.Build` | `BothPolicies_ShouldBlockPluginsAndBaseTagInjection` |
|
||||||
|
| BR-U3-18 composed once | `FrozenDictionary` in the constructor | `Provider_ShouldComposeBothPolicies_WithNoOriginsConfigured` |
|
||||||
|
| BR-U3-19 frame options per policy | `CspPolicyCatalog.Build` | `Strict_ShouldDenyFramingEntirely`, `Relaxed_ShouldAllowSameOriginFraming_…` |
|
||||||
|
| BR-U3-20 unknown name fatal | `ValidateOnStart` | `Validation_ShouldFail_ForAnUnknownPolicyNameInPathPolicies` |
|
||||||
|
| BR-U3-21 empty origin lists valid | `Join` returns the base list | `Validation_ShouldPass_WithNoConfigurationAtAll` |
|
||||||
|
| BR-U3-22 Umami origin warning | **Withdrawn — REF-U3-01**, moved to the U5 CI gate | n/a |
|
||||||
|
| BR-U3-23 log permitted origins | `UseCmsSecurityHeaders` | Carried to Build and Test |
|
||||||
|
| BR-U3-24 disable logs a warning | `UseCmsSecurityHeaders` | Carried to Build and Test |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deviations and Additions
|
||||||
|
|
||||||
|
**One rule withdrawn, one check added.** REF-U3-01 (raised at NFR Design): BR-U3-22's Umami-origin startup warning is not implementable, because the backend never sees `VITE_UMAMI_WEBSITE_ID`. Replaced by a blocking U5 CI gate. Nothing in the generated code attempts it.
|
||||||
|
|
||||||
|
**Origin format validation added beyond the functional design.** `AllowedScriptOrigins` and `AllowedConnectOrigins` are validated at startup to be scheme-and-host without a path. A CSP source list silently ignores a malformed source, so `https://analytics.example.com/script.js` in configuration would produce a policy that looks configured and blocks the script anyway. Caught at startup instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Carried to Phase-Level Build and Test
|
||||||
|
|
||||||
|
Everything below needs a real response feature or a running host — `DefaultHttpContext.Response.OnStarting` is a no-op, so none of it can be asserted in a unit test without asserting the wrong thing:
|
||||||
|
|
||||||
|
| Behaviour | Why it matters |
|
||||||
|
|---|---|
|
||||||
|
| Headers present on a **static website asset** | Verifies the registration sits before `UseCmsStaticContent`. A misordered registration compiles, starts, passes all 315 tests and serves the entire website with no CSP |
|
||||||
|
| Headers present on the availability gate's **503** and on an unhandled-exception **ProblemDetails** | Verifies the position inside `UseExceptionHandler` (BR-U3-09) |
|
||||||
|
| `/admin/dashboard` gets `DENY`, `/` gets `SAMEORIGIN` | End-to-end policy selection |
|
||||||
|
| `/admin/assets/*.js` gets only `nosniff` + HSTS | Content-type scoping against real static-file responses |
|
||||||
|
| Startup log lines for permitted origins and for `Enabled: false` | BR-U3-23, BR-U3-24 |
|
||||||
|
| Both hosts start with the new section present | `ValidateOnStart` against the committed `appsettings.json` |
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using SlpModularCms.Core.Hosting;
|
using SlpModularCms.Core.Hosting;
|
||||||
using SlpModularCms.Core.Hosting.Health;
|
using SlpModularCms.Core.Hosting.Health;
|
||||||
|
using SlpModularCms.Core.Hosting.Security;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -18,6 +19,11 @@ builder.Services.AddCmsCors(builder.Configuration);
|
|||||||
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
||||||
builder.Services.AddCmsHealthChecks();
|
builder.Services.AddCmsHealthChecks();
|
||||||
|
|
||||||
|
// This host serves no admin SPA and no public website, but it does serve /api/v1 and /health
|
||||||
|
// and it will be reached directly during diagnosis. There is no reason for it to be the one
|
||||||
|
// host without nosniff and HSTS. The path rules that do not apply here simply never match.
|
||||||
|
builder.Services.AddCmsSecurityHeaders(builder.Configuration);
|
||||||
|
|
||||||
// Registered BEFORE module services — see the note in DataProtectionExtensions.
|
// Registered BEFORE module services — see the note in DataProtectionExtensions.
|
||||||
builder.Services.AddCmsDataProtection();
|
builder.Services.AddCmsDataProtection();
|
||||||
|
|
||||||
@@ -43,6 +49,10 @@ app.MigrateCoreDatabase();
|
|||||||
// 5. Global Exception Handling
|
// 5. Global Exception Handling
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
|
|
||||||
|
// First thing inside the exception handler, same as the master host, so error responses carry
|
||||||
|
// the headers too.
|
||||||
|
app.UseCmsSecurityHeaders();
|
||||||
|
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
|
||||||
// 6. Configure Pipeline
|
// 6. Configure Pipeline
|
||||||
|
|||||||
@@ -37,5 +37,15 @@
|
|||||||
"PermitLimit": 20,
|
"PermitLimit": 20,
|
||||||
"WindowSeconds": 60
|
"WindowSeconds": 60
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SecurityHeaders": {
|
||||||
|
"Enabled": true,
|
||||||
|
"DefaultPolicy": "Relaxed",
|
||||||
|
"PathPolicies": [
|
||||||
|
{ "PathPrefix": "/api/v1", "Policy": "Strict" },
|
||||||
|
{ "PathPrefix": "/health", "Policy": "Strict" }
|
||||||
|
],
|
||||||
|
"AllowedScriptOrigins": [],
|
||||||
|
"AllowedConnectOrigins": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using SlpModularCms.Api.Extensions;
|
using SlpModularCms.Api.Extensions;
|
||||||
using SlpModularCms.Core.Hosting;
|
using SlpModularCms.Core.Hosting;
|
||||||
using SlpModularCms.Core.Hosting.Health;
|
using SlpModularCms.Core.Hosting.Health;
|
||||||
|
using SlpModularCms.Core.Hosting.Security;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -18,6 +19,7 @@ builder.Services.AddCoreInfrastructure(builder.Configuration);
|
|||||||
builder.Services.AddCmsCors(builder.Configuration);
|
builder.Services.AddCmsCors(builder.Configuration);
|
||||||
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
||||||
builder.Services.AddCmsHealthChecks();
|
builder.Services.AddCmsHealthChecks();
|
||||||
|
builder.Services.AddCmsSecurityHeaders(builder.Configuration);
|
||||||
|
|
||||||
// Registered BEFORE module services: modules must not configure Data Protection themselves,
|
// Registered BEFORE module services: modules must not configure Data Protection themselves,
|
||||||
// because a later registration would override this persistent key store (see
|
// because a later registration would override this persistent key store (see
|
||||||
@@ -50,6 +52,13 @@ app.MigrateCoreDatabase();
|
|||||||
// 5. Global Exception Handling
|
// 5. Global Exception Handling
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
|
|
||||||
|
// First thing INSIDE the exception handler, and before the static-file middleware below.
|
||||||
|
// Both directions matter: the exception handler re-executes the pipeline from within itself,
|
||||||
|
// so anything registered outside it never sees the ProblemDetails response; and static files
|
||||||
|
// short-circuit the pipeline, so anything after them is invisible to the public website —
|
||||||
|
// which is almost all of the HTML this host serves.
|
||||||
|
app.UseCmsSecurityHeaders();
|
||||||
|
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
|
||||||
// 6. Configure Pipeline
|
// 6. Configure Pipeline
|
||||||
|
|||||||
@@ -42,5 +42,16 @@
|
|||||||
"PermitLimit": 20,
|
"PermitLimit": 20,
|
||||||
"WindowSeconds": 60
|
"WindowSeconds": 60
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SecurityHeaders": {
|
||||||
|
"Enabled": true,
|
||||||
|
"DefaultPolicy": "Relaxed",
|
||||||
|
"PathPolicies": [
|
||||||
|
{ "PathPrefix": "/admin", "Policy": "Strict" },
|
||||||
|
{ "PathPrefix": "/api/v1", "Policy": "Strict" },
|
||||||
|
{ "PathPrefix": "/health", "Policy": "Strict" }
|
||||||
|
],
|
||||||
|
"AllowedScriptOrigins": [],
|
||||||
|
"AllowedConnectOrigins": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SlpModularCms.Core.Hosting.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the content of the two policies.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The strict policy's <c>script-src 'self'</c> is the directive that matters: relax it and the
|
||||||
|
/// value of the whole policy collapses. It is asserted here rather than left as a default so
|
||||||
|
/// that loosening it requires deleting a test that says why.
|
||||||
|
/// </remarks>
|
||||||
|
public class CspPolicyCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Strict_ShouldNotAllowInlineOrEvalScript()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("script-src 'self';");
|
||||||
|
set.ContentSecurityPolicy.Should().NotContain("script-src 'self' 'unsafe-inline'");
|
||||||
|
set.ContentSecurityPolicy.Should().NotContain("'unsafe-eval'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one documented exception. Radix UI positions its overlays with inline style
|
||||||
|
/// ATTRIBUTES, which no nonce or hash variant can permit, so this cannot be tightened
|
||||||
|
/// without breaking the admin UI. Bounded to style-src: injected CSS cannot execute.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Strict_ShouldAllowInlineStyle_ButOnlyStyle()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("style-src 'self' 'unsafe-inline'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Strict_ShouldDenyFramingEntirely()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||||
|
|
||||||
|
set.FrameOptions.Should().Be("DENY");
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("frame-ancestors 'none'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Relaxed_ShouldAllowSameOriginFraming_SoACustomerCanEmbedTheirOwnPages()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Relaxed, [], []);
|
||||||
|
|
||||||
|
set.FrameOptions.Should().Be("SAMEORIGIN");
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("frame-ancestors 'self'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Relaxed" must not mean "absent": a third-party script still requires its origin to be
|
||||||
|
/// added to configuration, which is what keeps the website policy from being a rubber stamp.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Relaxed_ShouldNotPermitExternalScriptOrigins_ByDefault()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Relaxed, [], []);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("script-src 'self' 'unsafe-inline';");
|
||||||
|
set.ContentSecurityPolicy.Should().NotContain("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Relaxed_ShouldAddConfiguredScriptOrigins()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(
|
||||||
|
CspPolicyCatalog.Relaxed,
|
||||||
|
["https://analytics.example.com"],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("script-src 'self' 'unsafe-inline' https://analytics.example.com;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configured script origins must never reach the admin UI's policy.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Strict_ShouldIgnoreConfiguredScriptOrigins()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(
|
||||||
|
CspPolicyCatalog.Strict,
|
||||||
|
["https://analytics.example.com"],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().NotContain("analytics.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CspPolicyCatalog.Strict)]
|
||||||
|
[InlineData(CspPolicyCatalog.Relaxed)]
|
||||||
|
public void BothPolicies_ShouldAddConfiguredConnectOrigins(string policy)
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(policy, [], ["https://api.example.com"]);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("connect-src 'self' https://api.example.com;");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CspPolicyCatalog.Strict)]
|
||||||
|
[InlineData(CspPolicyCatalog.Relaxed)]
|
||||||
|
public void BothPolicies_ShouldBlockPluginsAndBaseTagInjection(string policy)
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(policy, [], []);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("object-src 'none'");
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("base-uri 'self'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldIgnoreBlankAndDuplicateOrigins()
|
||||||
|
{
|
||||||
|
var set = CspPolicyCatalog.Build(
|
||||||
|
CspPolicyCatalog.Relaxed,
|
||||||
|
["https://a.example.com/", " ", "https://A.example.com"],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
set.ContentSecurityPolicy.Should().Contain("script-src 'self' 'unsafe-inline' https://a.example.com;");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldThrow_ForAnUnknownPolicy()
|
||||||
|
{
|
||||||
|
var act = () => CspPolicyCatalog.Build("Stricct", [], []);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Strict", true)]
|
||||||
|
[InlineData("strict", true)]
|
||||||
|
[InlineData("Relaxed", true)]
|
||||||
|
[InlineData("Stricct", false)]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
public void IsKnownPolicy_ShouldRecogniseOnlyTheTwoPolicies(string? policy, bool expected)
|
||||||
|
{
|
||||||
|
CspPolicyCatalog.IsKnownPolicy(policy).Should().Be(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SlpModularCms.Core.Hosting.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||||
|
|
||||||
|
public class PathPolicyResolverTests
|
||||||
|
{
|
||||||
|
private static PathPolicyResolver CreateDefault() =>
|
||||||
|
new(Options.Create(new SecurityHeadersOptions()));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/admin")]
|
||||||
|
[InlineData("/admin/")]
|
||||||
|
[InlineData("/admin/dashboard")]
|
||||||
|
[InlineData("/ADMIN/dashboard")]
|
||||||
|
[InlineData("/api/v1/Users")]
|
||||||
|
[InlineData("/health")]
|
||||||
|
public void Resolve_ShouldReturnStrict_ForAdminApiAndHealth(string path)
|
||||||
|
{
|
||||||
|
CreateDefault().Resolve(new PathString(path)).Should().Be(CspPolicyCatalog.Strict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/")]
|
||||||
|
[InlineData("/about")]
|
||||||
|
[InlineData("/assets/site.css")]
|
||||||
|
public void Resolve_ShouldReturnRelaxed_ForTheWebsite(string path)
|
||||||
|
{
|
||||||
|
CreateDefault().Resolve(new PathString(path)).Should().Be(CspPolicyCatalog.Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trap this resolver exists to avoid. <c>"/administrator".StartsWith("/admin")</c> is
|
||||||
|
/// true, so a naive prefix check would hand a public page the strict policy and strip its
|
||||||
|
/// inline scripts — a failure that appears only as a broken page and a browser console
|
||||||
|
/// error, with no server-side trace whatsoever.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/administrator")]
|
||||||
|
[InlineData("/admin-tools")]
|
||||||
|
[InlineData("/administration/contact")]
|
||||||
|
[InlineData("/healthcheck")]
|
||||||
|
[InlineData("/api/v10/Users")]
|
||||||
|
public void Resolve_ShouldNotMatchPartialSegments(string path)
|
||||||
|
{
|
||||||
|
CreateDefault().Resolve(new PathString(path)).Should().Be(CspPolicyCatalog.Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rules are evaluated in configured order and the first match wins — not the longest
|
||||||
|
/// prefix. Order in configuration is therefore meaningful, and this asserts it rather than
|
||||||
|
/// leaving readers to infer it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_ShouldUseFirstMatch_NotLongestPrefix()
|
||||||
|
{
|
||||||
|
var resolver = new PathPolicyResolver(Options.Create(new SecurityHeadersOptions
|
||||||
|
{
|
||||||
|
DefaultPolicy = CspPolicyCatalog.Strict,
|
||||||
|
PathPolicies =
|
||||||
|
[
|
||||||
|
new PathPolicyRule { PathPrefix = "/admin", Policy = CspPolicyCatalog.Relaxed },
|
||||||
|
new PathPolicyRule { PathPrefix = "/admin/preview", Policy = CspPolicyCatalog.Strict }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
resolver.Resolve(new PathString("/admin/preview")).Should().Be(CspPolicyCatalog.Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("admin")]
|
||||||
|
[InlineData("/admin/")]
|
||||||
|
[InlineData(" /admin ")]
|
||||||
|
public void Resolve_ShouldNormalisePrefixShapes(string configuredPrefix)
|
||||||
|
{
|
||||||
|
var resolver = new PathPolicyResolver(Options.Create(new SecurityHeadersOptions
|
||||||
|
{
|
||||||
|
DefaultPolicy = CspPolicyCatalog.Relaxed,
|
||||||
|
PathPolicies = [new PathPolicyRule { PathPrefix = configuredPrefix, Policy = CspPolicyCatalog.Strict }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
resolver.Resolve(new PathString("/admin/dashboard")).Should().Be(CspPolicyCatalog.Strict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_ShouldFallBackToDefault_WhenNoRulesAreConfigured()
|
||||||
|
{
|
||||||
|
var resolver = new PathPolicyResolver(Options.Create(new SecurityHeadersOptions
|
||||||
|
{
|
||||||
|
DefaultPolicy = CspPolicyCatalog.Relaxed,
|
||||||
|
PathPolicies = []
|
||||||
|
}));
|
||||||
|
|
||||||
|
resolver.Resolve(new PathString("/admin/dashboard")).Should().Be(CspPolicyCatalog.Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using SlpModularCms.Core.Hosting.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers per-header scoping, HSTS gating and the never-overwrite rule.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Tested against a bare <see cref="HeaderDictionary"/> rather than through the middleware,
|
||||||
|
/// because <c>DefaultHttpContext.Response.OnStarting</c> is a no-op: a middleware-level test
|
||||||
|
/// would observe no headers at all and an assertion that nothing was written would pass for
|
||||||
|
/// entirely the wrong reason.
|
||||||
|
/// </remarks>
|
||||||
|
public class SecurityHeaderWriterTests
|
||||||
|
{
|
||||||
|
private static readonly SecurityHeaderSet StrictSet =
|
||||||
|
CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("text/html")]
|
||||||
|
[InlineData("text/html; charset=utf-8")]
|
||||||
|
[InlineData("TEXT/HTML; charset=utf-8")]
|
||||||
|
public void Apply_ShouldWriteAllFiveHeaders_ForHtmlResponses(string contentType)
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary();
|
||||||
|
|
||||||
|
SecurityHeaderWriter.Apply(headers, contentType, StrictSet, sendHsts: true);
|
||||||
|
|
||||||
|
headers[SecurityHeaderWriter.ContentTypeOptionsHeader].ToString().Should().Be("nosniff");
|
||||||
|
headers[SecurityHeaderWriter.StrictTransportSecurityHeader].ToString().Should().Be(SecurityHeaderWriter.HstsValue);
|
||||||
|
headers[SecurityHeaderWriter.ContentSecurityPolicyHeader].ToString().Should().Be(StrictSet.ContentSecurityPolicy);
|
||||||
|
headers[SecurityHeaderWriter.FrameOptionsHeader].ToString().Should().Be("DENY");
|
||||||
|
headers[SecurityHeaderWriter.ReferrerPolicyHeader].ToString().Should().Be(StrictSet.ReferrerPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("application/json")]
|
||||||
|
[InlineData("text/javascript")]
|
||||||
|
[InlineData("image/png")]
|
||||||
|
[InlineData("application/xhtml+xml")]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void Apply_ShouldWriteOnlyAlwaysHeaders_ForNonHtmlResponses(string? contentType)
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary();
|
||||||
|
|
||||||
|
SecurityHeaderWriter.Apply(headers, contentType, StrictSet, sendHsts: true);
|
||||||
|
|
||||||
|
headers.Should().ContainKey(SecurityHeaderWriter.ContentTypeOptionsHeader);
|
||||||
|
headers.Should().ContainKey(SecurityHeaderWriter.StrictTransportSecurityHeader);
|
||||||
|
headers.Should().NotContainKey(SecurityHeaderWriter.ContentSecurityPolicyHeader);
|
||||||
|
headers.Should().NotContainKey(SecurityHeaderWriter.FrameOptionsHeader);
|
||||||
|
headers.Should().NotContainKey(SecurityHeaderWriter.ReferrerPolicyHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// nosniff must reach non-HTML responses in particular — that is the whole point of it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Apply_ShouldWriteNosniff_ForAScriptFile()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary();
|
||||||
|
|
||||||
|
SecurityHeaderWriter.Apply(headers, "text/javascript", StrictSet, sendHsts: false);
|
||||||
|
|
||||||
|
headers[SecurityHeaderWriter.ContentTypeOptionsHeader].ToString().Should().Be("nosniff");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_ShouldSkipHsts_WhenSendHstsIsFalse()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary();
|
||||||
|
|
||||||
|
SecurityHeaderWriter.Apply(headers, "text/html", StrictSet, sendHsts: false);
|
||||||
|
|
||||||
|
headers.Should().NotContainKey(SecurityHeaderWriter.StrictTransportSecurityHeader);
|
||||||
|
// Every other header still applies in Development, so a CSP violation surfaces there.
|
||||||
|
headers.Should().ContainKey(SecurityHeaderWriter.ContentSecurityPolicyHeader);
|
||||||
|
headers.Should().ContainKey(SecurityHeaderWriter.ContentTypeOptionsHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_ShouldNotOverwrite_HeadersAlreadyPresent()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
[SecurityHeaderWriter.ReferrerPolicyHeader] = "no-referrer",
|
||||||
|
[SecurityHeaderWriter.FrameOptionsHeader] = "SAMEORIGIN",
|
||||||
|
[SecurityHeaderWriter.ContentTypeOptionsHeader] = "custom"
|
||||||
|
};
|
||||||
|
|
||||||
|
SecurityHeaderWriter.Apply(headers, "text/html", StrictSet, sendHsts: true);
|
||||||
|
|
||||||
|
headers[SecurityHeaderWriter.ReferrerPolicyHeader].ToString().Should().Be("no-referrer");
|
||||||
|
headers[SecurityHeaderWriter.FrameOptionsHeader].ToString().Should().Be("SAMEORIGIN");
|
||||||
|
headers[SecurityHeaderWriter.ContentTypeOptionsHeader].ToString().Should().Be("custom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_ShouldUseOneYearHsts_WithSubdomains()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary();
|
||||||
|
|
||||||
|
SecurityHeaderWriter.Apply(headers, null, StrictSet, sendHsts: true);
|
||||||
|
|
||||||
|
headers[SecurityHeaderWriter.StrictTransportSecurityHeader].ToString()
|
||||||
|
.Should().Be("max-age=31536000; includeSubDomains");
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SlpModularCms.Core.Hosting.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A typo in a path-policy mapping must stop a deployment, not quietly serve /admin under the
|
||||||
|
/// relaxed policy. These tests drive the real options pipeline, because the guarantee comes
|
||||||
|
/// from <c>ValidateOnStart</c> rather than from any code that could be tested in isolation.
|
||||||
|
/// </summary>
|
||||||
|
public class SecurityHeadersOptionsValidationTests
|
||||||
|
{
|
||||||
|
private static IServiceProvider BuildProvider(Dictionary<string, string?> settings)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddCmsSecurityHeaders(configuration);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(IServiceProvider provider) =>
|
||||||
|
provider.GetRequiredService<IStartupValidator>().Validate();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validation_ShouldPass_WithNoConfigurationAtAll()
|
||||||
|
{
|
||||||
|
// The built-in defaults are the production values: an environment that supplies no
|
||||||
|
// SecurityHeaders section still gets Strict on /admin, /api/v1 and /health.
|
||||||
|
var provider = BuildProvider([]);
|
||||||
|
|
||||||
|
var act = () => Validate(provider);
|
||||||
|
|
||||||
|
act.Should().NotThrow();
|
||||||
|
|
||||||
|
var options = provider.GetRequiredService<IOptions<SecurityHeadersOptions>>().Value;
|
||||||
|
options.Enabled.Should().BeTrue();
|
||||||
|
options.DefaultPolicy.Should().Be(CspPolicyCatalog.Relaxed);
|
||||||
|
options.PathPolicies.Should().HaveCount(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validation_ShouldFail_ForAnUnknownPolicyNameInPathPolicies()
|
||||||
|
{
|
||||||
|
var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["SecurityHeaders:PathPolicies:0:PathPrefix"] = "/admin",
|
||||||
|
["SecurityHeaders:PathPolicies:0:Policy"] = "Stricct"
|
||||||
|
});
|
||||||
|
|
||||||
|
var act = () => Validate(provider);
|
||||||
|
|
||||||
|
act.Should().Throw<OptionsValidationException>()
|
||||||
|
.WithMessage("*unknown policy name*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validation_ShouldFail_ForAnUnknownDefaultPolicy()
|
||||||
|
{
|
||||||
|
var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["SecurityHeaders:DefaultPolicy"] = "Permissive"
|
||||||
|
});
|
||||||
|
|
||||||
|
var act = () => Validate(provider);
|
||||||
|
|
||||||
|
act.Should().Throw<OptionsValidationException>()
|
||||||
|
.WithMessage("*DefaultPolicy*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("analytics.example.com")] // no scheme
|
||||||
|
[InlineData("https://analytics.example.com/script.js")] // has a path
|
||||||
|
[InlineData("htp://analytics.example.com")] // typo in the scheme
|
||||||
|
public void Validation_ShouldFail_ForOriginsThatAreNotSchemeAndHost(string origin)
|
||||||
|
{
|
||||||
|
var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["SecurityHeaders:AllowedScriptOrigins:0"] = origin
|
||||||
|
});
|
||||||
|
|
||||||
|
var act = () => Validate(provider);
|
||||||
|
|
||||||
|
act.Should().Throw<OptionsValidationException>()
|
||||||
|
.WithMessage("*origins must be absolute scheme-and-host*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://analytics.example.com")]
|
||||||
|
[InlineData("https://analytics.example.com/")]
|
||||||
|
[InlineData("http://localhost:3000")]
|
||||||
|
public void Validation_ShouldPass_ForValidOrigins(string origin)
|
||||||
|
{
|
||||||
|
var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["SecurityHeaders:AllowedScriptOrigins:0"] = origin
|
||||||
|
});
|
||||||
|
|
||||||
|
var act = () => Validate(provider);
|
||||||
|
|
||||||
|
act.Should().NotThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Empty origin lists are a normal state; the policy is simply stricter.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Provider_ShouldComposeBothPolicies_WithNoOriginsConfigured()
|
||||||
|
{
|
||||||
|
var provider = BuildProvider([]);
|
||||||
|
|
||||||
|
var policyProvider = provider.GetRequiredService<ICspPolicyProvider>();
|
||||||
|
|
||||||
|
policyProvider.Get(CspPolicyCatalog.Strict).ContentSecurityPolicy.Should().Contain("script-src 'self';");
|
||||||
|
policyProvider.Get(CspPolicyCatalog.Relaxed).FrameOptions.Should().Be("SAMEORIGIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Provider_ShouldThrow_ForAnUnknownPolicyName()
|
||||||
|
{
|
||||||
|
var provider = BuildProvider([]);
|
||||||
|
|
||||||
|
var act = () => provider.GetRequiredService<ICspPolicyProvider>().Get("Stricct");
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
namespace SlpModularCms.Core.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The three HTML-only header values belonging to one policy.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SecurityHeaderSet(
|
||||||
|
string ContentSecurityPolicy,
|
||||||
|
string FrameOptions,
|
||||||
|
string ReferrerPolicy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two Content-Security-Policy definitions, in code rather than in configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A pure function of (policy name, origin lists) to a header set: no dependencies, no state,
|
||||||
|
/// directly unit-testable. Configuration can misroute a path to the wrong policy — visible and
|
||||||
|
/// recoverable — but cannot invent a policy that is subtly permissive.
|
||||||
|
/// </remarks>
|
||||||
|
public static class CspPolicyCatalog
|
||||||
|
{
|
||||||
|
public const string Strict = "Strict";
|
||||||
|
public const string Relaxed = "Relaxed";
|
||||||
|
|
||||||
|
private const string ReferrerPolicyValue = "strict-origin-when-cross-origin";
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> KnownPolicies { get; } = [Strict, Relaxed];
|
||||||
|
|
||||||
|
public static bool IsKnownPolicy(string? policy) =>
|
||||||
|
policy is not null &&
|
||||||
|
KnownPolicies.Any(known => known.Equals(policy, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the header set for <paramref name="policy"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">The policy name is not known. Callers reach this only
|
||||||
|
/// past startup validation, so it indicates a code defect rather than a configuration one.</exception>
|
||||||
|
public static SecurityHeaderSet Build(
|
||||||
|
string policy,
|
||||||
|
IEnumerable<string> allowedScriptOrigins,
|
||||||
|
IEnumerable<string> allowedConnectOrigins)
|
||||||
|
{
|
||||||
|
var connect = Join("'self'", allowedConnectOrigins);
|
||||||
|
|
||||||
|
if (Strict.Equals(policy, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new SecurityHeaderSet(
|
||||||
|
ContentSecurityPolicy: string.Join("; ",
|
||||||
|
"default-src 'self'",
|
||||||
|
// The directive that matters. No 'unsafe-inline' and no 'unsafe-eval':
|
||||||
|
// relax this and the value of the whole policy collapses.
|
||||||
|
"script-src 'self'",
|
||||||
|
// The one exception, and it is unavoidable. Radix UI positions dropdowns,
|
||||||
|
// dialogs and selects with inline style ATTRIBUTES whose values are
|
||||||
|
// recomputed per click, viewport and scroll position. CSP nonces apply only
|
||||||
|
// to <style> and <script> ELEMENTS; inline style attributes are outside
|
||||||
|
// their scope entirely. The alternatives are 'unsafe-inline' or
|
||||||
|
// 'unsafe-hashes' with a hash per exact value — and the values are dynamic,
|
||||||
|
// so no finite set exists. Bounded to style-src: injected CSS can restyle a
|
||||||
|
// page but cannot execute, because script-src 'self' still holds.
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self'",
|
||||||
|
$"connect-src {connect}",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"object-src 'none'"),
|
||||||
|
FrameOptions: "DENY",
|
||||||
|
ReferrerPolicy: ReferrerPolicyValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Relaxed.Equals(policy, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new SecurityHeaderSet(
|
||||||
|
ContentSecurityPolicy: string.Join("; ",
|
||||||
|
"default-src 'self'",
|
||||||
|
// The public website is authored elsewhere, by someone who has never seen
|
||||||
|
// this policy. Inline script and style are permitted so a normal marketing
|
||||||
|
// page is not broken by a policy its author never chose. External script
|
||||||
|
// ORIGINS are still blocked — a third-party script requires adding its
|
||||||
|
// origin to configuration, which keeps this from being a rubber stamp.
|
||||||
|
//
|
||||||
|
// Note: when a source list contains 'unsafe-inline', browsers honour it AND
|
||||||
|
// the listed origins, so adding an origin does not disable inline scripts.
|
||||||
|
$"script-src {Join("'self' 'unsafe-inline'", allowedScriptOrigins)}",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: https:",
|
||||||
|
"font-src 'self' data: https:",
|
||||||
|
$"connect-src {connect}",
|
||||||
|
"frame-src 'self' https:",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"object-src 'none'"),
|
||||||
|
// A customer embedding one of their own pages in an iframe on their own site is
|
||||||
|
// not broken by a policy they never chose. The admin UI keeps DENY.
|
||||||
|
FrameOptions: "SAMEORIGIN",
|
||||||
|
ReferrerPolicy: ReferrerPolicyValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Unknown security policy '{policy}'.", nameof(policy));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Join(string baseSources, IEnumerable<string> extraOrigins)
|
||||||
|
{
|
||||||
|
var extras = extraOrigins
|
||||||
|
.Where(origin => !string.IsNullOrWhiteSpace(origin))
|
||||||
|
.Select(origin => origin.Trim().TrimEnd('/'))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return extras.Length == 0 ? baseSources : $"{baseSources} {string.Join(' ', extras)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>Supplies the precomposed header set for a policy name.</summary>
|
||||||
|
public interface ICspPolicyProvider
|
||||||
|
{
|
||||||
|
/// <summary>Returns the header set for <paramref name="policyName"/>.</summary>
|
||||||
|
SecurityHeaderSet Get(string policyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes every known policy once, at startup, and serves them from a frozen dictionary.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Composing a CSP string per response would be wasteful on a workload that is mostly static
|
||||||
|
/// files. There are two policies and they are read on every HTML response, which is exactly
|
||||||
|
/// what <see cref="FrozenDictionary{TKey,TValue}"/> exists for.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CspPolicyProvider : ICspPolicyProvider
|
||||||
|
{
|
||||||
|
private readonly FrozenDictionary<string, SecurityHeaderSet> _policies;
|
||||||
|
|
||||||
|
public CspPolicyProvider(IOptions<SecurityHeadersOptions> options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
var value = options.Value;
|
||||||
|
|
||||||
|
_policies = CspPolicyCatalog.KnownPolicies
|
||||||
|
.ToFrozenDictionary(
|
||||||
|
policy => policy,
|
||||||
|
policy => CspPolicyCatalog.Build(policy, value.AllowedScriptOrigins, value.AllowedConnectOrigins),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SecurityHeaderSet Get(string policyName) =>
|
||||||
|
_policies.TryGetValue(policyName, out var set)
|
||||||
|
? set
|
||||||
|
// Unreachable past startup validation, which rejects unknown names before the host
|
||||||
|
// starts. Throwing rather than falling back keeps that guarantee honest: a silent
|
||||||
|
// fallback here would be either wrong or permissive, and would hide the fact that
|
||||||
|
// validation had been bypassed.
|
||||||
|
: throw new ArgumentException($"Unknown security policy '{policyName}'.", nameof(policyName));
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a request path to a policy name using the configured, ordered prefix rules.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PathPolicyResolver
|
||||||
|
{
|
||||||
|
private readonly (PathString Prefix, string Policy)[] _rules;
|
||||||
|
private readonly string _defaultPolicy;
|
||||||
|
|
||||||
|
public PathPolicyResolver(IOptions<SecurityHeadersOptions> options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
var value = options.Value;
|
||||||
|
|
||||||
|
_rules = value.PathPolicies
|
||||||
|
.Where(rule => !string.IsNullOrWhiteSpace(rule.PathPrefix))
|
||||||
|
.Select(rule => (Normalize(rule.PathPrefix), rule.Policy))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
_defaultPolicy = value.DefaultPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the policy name for <paramref name="path"/>. First matching rule wins.
|
||||||
|
/// </summary>
|
||||||
|
public string Resolve(PathString path)
|
||||||
|
{
|
||||||
|
foreach (var (prefix, policy) in _rules)
|
||||||
|
{
|
||||||
|
// StartsWithSegments, never string.StartsWith: "/administrator".StartsWith("/admin")
|
||||||
|
// is true, so a future route named /admin-tools or a public page at /administration
|
||||||
|
// would silently inherit the strict policy and lose its inline scripts — a failure
|
||||||
|
// that shows up as a broken page with a browser console error and no server-side
|
||||||
|
// trace at all. StartsWithSegments compares whole path segments and does not match.
|
||||||
|
if (path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _defaultPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accepts "admin", "/admin" and "/admin/" alike. <see cref="PathString"/> requires a
|
||||||
|
/// leading slash and treats a trailing slash inconsistently across overloads, so the shape
|
||||||
|
/// is fixed once here rather than depended on per rule.
|
||||||
|
/// </summary>
|
||||||
|
private static PathString Normalize(string prefix)
|
||||||
|
{
|
||||||
|
var trimmed = prefix.Trim().TrimEnd('/');
|
||||||
|
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
{
|
||||||
|
return PathString.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PathString(trimmed.StartsWith('/') ? trimmed : "/" + trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides which security headers apply to a response and writes them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// All of U3's decision logic lives here, in a static function over an
|
||||||
|
/// <see cref="IHeaderDictionary"/>, deliberately: <c>DefaultHttpContext.Response.OnStarting</c>
|
||||||
|
/// is a no-op — there is no response feature to trigger it — so a unit test that drives the
|
||||||
|
/// middleware through a <c>DefaultHttpContext</c> finds no headers and looks like a bug. Worse,
|
||||||
|
/// a test asserting that nothing was set passes for the wrong reason and keeps passing after
|
||||||
|
/// the middleware is deleted. Testing this function directly against a bare
|
||||||
|
/// <c>HeaderDictionary</c> avoids both, and leaves the middleware as glue whose only real risk
|
||||||
|
/// is registration order.
|
||||||
|
/// </remarks>
|
||||||
|
public static class SecurityHeaderWriter
|
||||||
|
{
|
||||||
|
public const string ContentTypeOptionsHeader = "X-Content-Type-Options";
|
||||||
|
public const string StrictTransportSecurityHeader = "Strict-Transport-Security";
|
||||||
|
public const string ContentSecurityPolicyHeader = "Content-Security-Policy";
|
||||||
|
public const string FrameOptionsHeader = "X-Frame-Options";
|
||||||
|
public const string ReferrerPolicyHeader = "Referrer-Policy";
|
||||||
|
|
||||||
|
/// <summary>One year, with subdomains — the value SECURITY-04 asks for.</summary>
|
||||||
|
public const string HstsValue = "max-age=31536000; includeSubDomains";
|
||||||
|
|
||||||
|
private const string HtmlMediaType = "text/html";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the headers appropriate to this response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="headers">Response headers, mutated in place.</param>
|
||||||
|
/// <param name="contentType">Raw <c>Content-Type</c>; null for bodyless responses.</param>
|
||||||
|
/// <param name="headerSet">The resolved policy's HTML-only header values.</param>
|
||||||
|
/// <param name="sendHsts">False in Development only; see <see cref="SecurityHeadersMiddleware"/>.</param>
|
||||||
|
public static void Apply(
|
||||||
|
IHeaderDictionary headers,
|
||||||
|
string? contentType,
|
||||||
|
SecurityHeaderSet headerSet,
|
||||||
|
bool sendHsts)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(headers);
|
||||||
|
ArgumentNullException.ThrowIfNull(headerSet);
|
||||||
|
|
||||||
|
// nosniff exists to stop a browser guessing the type of a NON-HTML resource — an
|
||||||
|
// uploaded .txt or .svg interpreted as HTML or JavaScript is the attack it prevents.
|
||||||
|
// Restricting it to HTML would remove it precisely where it does its job.
|
||||||
|
headers.TryAdd(ContentTypeOptionsHeader, "nosniff");
|
||||||
|
|
||||||
|
// A host-level transport directive rather than a page directive: a visitor whose first
|
||||||
|
// request is an asset would otherwise never receive it.
|
||||||
|
if (sendHsts)
|
||||||
|
{
|
||||||
|
headers.TryAdd(StrictTransportSecurityHeader, HstsValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remaining three govern documents and are meaningless on an image or a script
|
||||||
|
// file. A null content type — 304, redirect, empty body — is not HTML, which is what
|
||||||
|
// makes those cases fall out here instead of needing branches of their own.
|
||||||
|
if (!IsHtml(contentType))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.TryAdd(ContentSecurityPolicyHeader, headerSet.ContentSecurityPolicy);
|
||||||
|
headers.TryAdd(FrameOptionsHeader, headerSet.FrameOptions);
|
||||||
|
headers.TryAdd(ReferrerPolicyHeader, headerSet.ReferrerPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// TryAdd, never the indexer: <c>headers["X-Frame-Options"] = value</c> overwrites, and a
|
||||||
|
/// component that deliberately set a header knows something this writer does not.
|
||||||
|
/// TryAdd is a no-op when the key exists, which is exactly the rule, and it states the
|
||||||
|
/// intent in the call rather than in a surrounding if.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IsHtml(string? contentType)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(contentType))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsed rather than matched with Contains("text/html"): the real header is
|
||||||
|
// "text/html; charset=utf-8", so a substring check happens to work — until something
|
||||||
|
// serves application/xhtml+xml or a type whose PARAMETER contains the string.
|
||||||
|
return MediaTypeHeaderValue.TryParse(contentType, out var parsed)
|
||||||
|
&& parsed.MediaType.Equals(HtmlMediaType, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Hosting.Security;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public static class SecurityHeadersExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddCmsSecurityHeaders(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<SecurityHeadersOptions>()
|
||||||
|
.Bind(configuration.GetSection(SecurityHeadersOptions.SectionName))
|
||||||
|
.Validate(
|
||||||
|
options => options.PathPolicies.All(rule => CspPolicyCatalog.IsKnownPolicy(rule.Policy)),
|
||||||
|
$"SecurityHeaders:PathPolicies contains an unknown policy name. Known policies: {string.Join(", ", CspPolicyCatalog.KnownPolicies)}.")
|
||||||
|
.Validate(
|
||||||
|
options => CspPolicyCatalog.IsKnownPolicy(options.DefaultPolicy),
|
||||||
|
$"SecurityHeaders:DefaultPolicy is not a known policy. Known policies: {string.Join(", ", CspPolicyCatalog.KnownPolicies)}.")
|
||||||
|
.Validate(
|
||||||
|
options => options.AllowedScriptOrigins.Concat(options.AllowedConnectOrigins).All(IsOrigin),
|
||||||
|
"SecurityHeaders origins must be absolute scheme-and-host values without a path, e.g. https://analytics.example.com.")
|
||||||
|
// ValidateOnStart, not a constructor check: options resolve lazily, so without this
|
||||||
|
// a typo in PathPolicies is discovered when the first request arrives — by which
|
||||||
|
// time the deployment has been reported successful and /health is green. This
|
||||||
|
// registers a startup validator, so the process exits non-zero instead and the
|
||||||
|
// release switch is visibly broken. Fail closed (SECURITY-15): any fallback would
|
||||||
|
// be either wrong or silently permissive.
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddSingleton<ICspPolicyProvider, CspPolicyProvider>();
|
||||||
|
services.AddSingleton<PathPolicyResolver>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the security headers middleware.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Position matters in both directions.</b> Call this first inside
|
||||||
|
/// <c>UseExceptionHandler()</c>: the exception handler re-executes the pipeline from inside
|
||||||
|
/// itself, so middleware registered outside it never observes the ProblemDetails response.
|
||||||
|
/// And call it BEFORE <c>UseCmsStaticContent()</c>, because static-file middleware
|
||||||
|
/// terminates the request — anything after it is invisible to the public website, which is
|
||||||
|
/// almost all of the HTML this host serves.
|
||||||
|
/// </remarks>
|
||||||
|
public static IApplicationBuilder UseCmsSecurityHeaders(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
var options = app.ApplicationServices.GetRequiredService<IOptions<SecurityHeadersOptions>>().Value;
|
||||||
|
var logger = app.ApplicationServices
|
||||||
|
.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger(typeof(SecurityHeadersExtensions).FullName!);
|
||||||
|
|
||||||
|
if (!options.Enabled)
|
||||||
|
{
|
||||||
|
// A diagnostic escape hatch is worth having, but silently disabled security headers
|
||||||
|
// are worse than none at all, so switching them off announces itself.
|
||||||
|
logger.LogWarning(
|
||||||
|
"HTTP security headers are DISABLED by configuration (SecurityHeaders:Enabled = false). " +
|
||||||
|
"No CSP, HSTS, nosniff, frame or referrer headers will be sent.");
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records what was actually permitted, so the log answers the question later rather
|
||||||
|
// than requiring someone to reconstruct it from the deployed appsettings.
|
||||||
|
logger.LogInformation(
|
||||||
|
"HTTP security headers enabled. Default policy: {DefaultPolicy}. Path policies: {PathPolicies}. " +
|
||||||
|
"Allowed script origins: {ScriptOrigins}. Allowed connect origins: {ConnectOrigins}.",
|
||||||
|
options.DefaultPolicy,
|
||||||
|
string.Join(", ", options.PathPolicies.Select(rule => $"{rule.PathPrefix} => {rule.Policy}")),
|
||||||
|
Describe(options.AllowedScriptOrigins),
|
||||||
|
Describe(options.AllowedConnectOrigins));
|
||||||
|
|
||||||
|
return app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe(ICollection<string> origins) =>
|
||||||
|
origins.Count == 0 ? "(none)" : string.Join(", ", origins);
|
||||||
|
|
||||||
|
private static bool IsOrigin(string origin) =>
|
||||||
|
!string.IsNullOrWhiteSpace(origin)
|
||||||
|
&& Uri.TryCreate(origin.TrimEnd('/'), UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)
|
||||||
|
&& uri.AbsolutePath == "/"
|
||||||
|
&& string.IsNullOrEmpty(uri.Query);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace SlpModularCms.Core.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the HTTP security headers that a reverse proxy would normally supply.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// NFR-01 forbids depending on server configuration, so the application must send these itself.
|
||||||
|
/// That changes the failure mode: a bad nginx config fails loudly at reload, whereas a
|
||||||
|
/// middleware that never runs sends nothing and says nothing — which is why registration order
|
||||||
|
/// is asserted by an integration test rather than trusted to the comment at the call site.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SecurityHeadersMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ICspPolicyProvider _policyProvider;
|
||||||
|
private readonly PathPolicyResolver _resolver;
|
||||||
|
private readonly ILogger<SecurityHeadersMiddleware> _logger;
|
||||||
|
private readonly bool _enabled;
|
||||||
|
private readonly bool _sendHsts;
|
||||||
|
|
||||||
|
public SecurityHeadersMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
IOptions<SecurityHeadersOptions> options,
|
||||||
|
ICspPolicyProvider policyProvider,
|
||||||
|
PathPolicyResolver resolver,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
ILogger<SecurityHeadersMiddleware> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(environment);
|
||||||
|
|
||||||
|
_next = next;
|
||||||
|
_policyProvider = policyProvider;
|
||||||
|
_resolver = resolver;
|
||||||
|
_logger = logger;
|
||||||
|
_enabled = options.Value.Enabled;
|
||||||
|
|
||||||
|
// Browsers remember HSTS per host for a long time, and localhost is shared with every
|
||||||
|
// other local project — sending it during development would affect unrelated work and
|
||||||
|
// is awkward to undo. Every OTHER header does apply in Development, so a CSP violation
|
||||||
|
// surfaces while developing rather than in production.
|
||||||
|
//
|
||||||
|
// Resolved once here rather than per response: EnvironmentName cannot change while the
|
||||||
|
// process runs, and this middleware sees every static asset the website serves.
|
||||||
|
_sendHsts = !environment.IsDevelopment();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (!_enabled)
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The policy is resolved on the way in, from the path; the headers are written at
|
||||||
|
// response start, because the content type — which decides whether the HTML-only
|
||||||
|
// headers apply — is not known any earlier. Doing both at response start would repeat
|
||||||
|
// path matching on every static asset.
|
||||||
|
var headerSet = _policyProvider.Get(_resolver.Resolve(context.Request.Path));
|
||||||
|
|
||||||
|
// The (callback, state) overload with a cached static delegate. The lambda overload
|
||||||
|
// allocates a closure and a delegate on every response, including every static asset of
|
||||||
|
// the public website — the bulk of the traffic in this hosting model.
|
||||||
|
context.Response.OnStarting(
|
||||||
|
WriteHeadersCallback,
|
||||||
|
new HeaderWriteState(context.Response, headerSet, _sendHsts, _logger));
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Func<object, Task> WriteHeadersCallback = static state =>
|
||||||
|
{
|
||||||
|
var s = (HeaderWriteState)state;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SecurityHeaderWriter.Apply(s.Response.Headers, s.Response.ContentType, s.HeaderSet, s.SendHsts);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Deliberately the one place in this unit that swallows. An exception thrown from an
|
||||||
|
// OnStarting callback surfaces after the response has begun — too late for the
|
||||||
|
// exception handler — and produces a truncated or malformed response instead of a
|
||||||
|
// diagnosable error. Startup validation is what keeps this catch honest: by the time
|
||||||
|
// a request arrives the configuration is already known good, so anything reaching
|
||||||
|
// here is a code defect and belongs in the log.
|
||||||
|
s.Logger.LogError(ex, "Failed to apply security headers to the response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record HeaderWriteState(
|
||||||
|
HttpResponse Response,
|
||||||
|
SecurityHeaderSet HeaderSet,
|
||||||
|
bool SendHsts,
|
||||||
|
ILogger Logger);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
namespace SlpModularCms.Core.Hosting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the HTTP security headers emitted by <see cref="SecurityHeadersMiddleware"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The split between this class and <see cref="CspPolicyCatalog"/> is deliberate: configuration
|
||||||
|
/// decides WHERE a policy applies and WHICH external origins are permitted, while code decides
|
||||||
|
/// WHAT a policy means. A misconfiguration can therefore misroute a path or omit an origin —
|
||||||
|
/// both recoverable and both visible — but cannot produce a policy that is subtly wrong.
|
||||||
|
///
|
||||||
|
/// The defaults below are the production values. An environment that supplies no
|
||||||
|
/// <c>SecurityHeaders</c> section at all still gets the strict policy on /admin, /api/v1 and
|
||||||
|
/// /health and the relaxed policy elsewhere: omission cannot produce an unprotected host.
|
||||||
|
///
|
||||||
|
/// No secret belongs in this section — origins are public hostnames — so it is committed with
|
||||||
|
/// real values per environment rather than supplied through environment variables (contrast
|
||||||
|
/// D-16, which governs credentials).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SecurityHeadersOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "SecurityHeaders";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic escape hatch. Disabling is logged as a warning at startup, because silently
|
||||||
|
/// disabled security headers are worse than none at all.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Policy applied when no <see cref="PathPolicies"/> prefix matches — the public website.
|
||||||
|
/// </summary>
|
||||||
|
public string DefaultPolicy { get; set; } = CspPolicyCatalog.Relaxed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered path-prefix to policy mapping. <b>First match wins</b>, not longest prefix, so
|
||||||
|
/// the order in configuration is meaningful.
|
||||||
|
/// </summary>
|
||||||
|
public IList<PathPolicyRule> PathPolicies { get; set; } =
|
||||||
|
[
|
||||||
|
new() { PathPrefix = "/admin", Policy = CspPolicyCatalog.Strict },
|
||||||
|
new() { PathPrefix = "/api/v1", Policy = CspPolicyCatalog.Strict },
|
||||||
|
new() { PathPrefix = "/health", Policy = CspPolicyCatalog.Strict }
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra origins added to <c>script-src</c> under the relaxed policy — in practice the
|
||||||
|
/// Umami script host. Never added to the strict policy: the admin SPA must not load script
|
||||||
|
/// from anywhere but itself.
|
||||||
|
/// </summary>
|
||||||
|
public IList<string> AllowedScriptOrigins { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra origins added to <c>connect-src</c> under both policies.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// No Sentry ingest origin is expected here: the Sentry tunnel keeps browser error
|
||||||
|
/// reporting same-origin, so <c>connect-src 'self'</c> already covers it.
|
||||||
|
/// </remarks>
|
||||||
|
public IList<string> AllowedConnectOrigins { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One entry in <see cref="SecurityHeadersOptions.PathPolicies"/>.</summary>
|
||||||
|
public sealed class PathPolicyRule
|
||||||
|
{
|
||||||
|
/// <summary>Matched case-insensitively against whole leading path segments.</summary>
|
||||||
|
public string PathPrefix { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Must name a policy known to <see cref="CspPolicyCatalog"/>, or startup fails.</summary>
|
||||||
|
public string Policy { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user