Feature/gitea deployment workflow #1

Merged
Sluijsens merged 35 commits from feature/gitea-deployment-workflow into master 2026-07-29 16:50:44 +02:00
16 changed files with 1195 additions and 0 deletions
Showing only changes of commit a122548454 - Show all commits
@@ -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` |
+10
View File
@@ -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": []
} }
} }
+9
View File
@@ -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
+11
View File
@@ -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");
}
}
@@ -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;
}