diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/code/generation-summary.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/code/generation-summary.md
new file mode 100644
index 0000000..a716c2b
--- /dev/null
+++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/code/generation-summary.md
@@ -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` |
diff --git a/src/SlpModularCms.Api.Slave/Program.cs b/src/SlpModularCms.Api.Slave/Program.cs
index 9b65f9c..d544d94 100644
--- a/src/SlpModularCms.Api.Slave/Program.cs
+++ b/src/SlpModularCms.Api.Slave/Program.cs
@@ -1,5 +1,6 @@
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Health;
+using SlpModularCms.Core.Hosting.Security;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
@@ -18,6 +19,11 @@ builder.Services.AddCmsCors(builder.Configuration);
builder.Services.AddCmsRateLimiting(builder.Configuration);
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.
builder.Services.AddCmsDataProtection();
@@ -43,6 +49,10 @@ app.MigrateCoreDatabase();
// 5. Global Exception Handling
app.UseExceptionHandler();
+// First thing inside the exception handler, same as the master host, so error responses carry
+// the headers too.
+app.UseCmsSecurityHeaders();
+
app.UseRateLimiter();
// 6. Configure Pipeline
diff --git a/src/SlpModularCms.Api.Slave/appsettings.json b/src/SlpModularCms.Api.Slave/appsettings.json
index 78ec923..78bd163 100644
--- a/src/SlpModularCms.Api.Slave/appsettings.json
+++ b/src/SlpModularCms.Api.Slave/appsettings.json
@@ -37,5 +37,15 @@
"PermitLimit": 20,
"WindowSeconds": 60
}
+ },
+ "SecurityHeaders": {
+ "Enabled": true,
+ "DefaultPolicy": "Relaxed",
+ "PathPolicies": [
+ { "PathPrefix": "/api/v1", "Policy": "Strict" },
+ { "PathPrefix": "/health", "Policy": "Strict" }
+ ],
+ "AllowedScriptOrigins": [],
+ "AllowedConnectOrigins": []
}
}
diff --git a/src/SlpModularCms.Api/Program.cs b/src/SlpModularCms.Api/Program.cs
index 9002f43..6c1447e 100644
--- a/src/SlpModularCms.Api/Program.cs
+++ b/src/SlpModularCms.Api/Program.cs
@@ -1,6 +1,7 @@
using SlpModularCms.Api.Extensions;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Health;
+using SlpModularCms.Core.Hosting.Security;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
@@ -18,6 +19,7 @@ builder.Services.AddCoreInfrastructure(builder.Configuration);
builder.Services.AddCmsCors(builder.Configuration);
builder.Services.AddCmsRateLimiting(builder.Configuration);
builder.Services.AddCmsHealthChecks();
+builder.Services.AddCmsSecurityHeaders(builder.Configuration);
// Registered BEFORE module services: modules must not configure Data Protection themselves,
// because a later registration would override this persistent key store (see
@@ -50,6 +52,13 @@ app.MigrateCoreDatabase();
// 5. Global Exception Handling
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();
// 6. Configure Pipeline
diff --git a/src/SlpModularCms.Api/appsettings.json b/src/SlpModularCms.Api/appsettings.json
index 4d7a6a8..2ffd1f6 100644
--- a/src/SlpModularCms.Api/appsettings.json
+++ b/src/SlpModularCms.Api/appsettings.json
@@ -42,5 +42,16 @@
"PermitLimit": 20,
"WindowSeconds": 60
}
+ },
+ "SecurityHeaders": {
+ "Enabled": true,
+ "DefaultPolicy": "Relaxed",
+ "PathPolicies": [
+ { "PathPrefix": "/admin", "Policy": "Strict" },
+ { "PathPrefix": "/api/v1", "Policy": "Strict" },
+ { "PathPrefix": "/health", "Policy": "Strict" }
+ ],
+ "AllowedScriptOrigins": [],
+ "AllowedConnectOrigins": []
}
}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Security/CspPolicyCatalogTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Security/CspPolicyCatalogTests.cs
new file mode 100644
index 0000000..ad95f53
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Security/CspPolicyCatalogTests.cs
@@ -0,0 +1,147 @@
+using FluentAssertions;
+using SlpModularCms.Core.Hosting.Security;
+using Xunit;
+
+namespace SlpModularCms.Core.Tests.Hosting.Security;
+
+///
+/// Guards the content of the two policies.
+///
+///
+/// The strict policy's script-src 'self' 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.
+///
+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'");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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'");
+ }
+
+ ///
+ /// "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.
+ ///
+ [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;");
+ }
+
+ ///
+ /// Configured script origins must never reach the admin UI's policy.
+ ///
+ [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();
+ }
+
+ [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);
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Security/PathPolicyResolverTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Security/PathPolicyResolverTests.cs
new file mode 100644
index 0000000..2a4414c
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Security/PathPolicyResolverTests.cs
@@ -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);
+ }
+
+ ///
+ /// The trap this resolver exists to avoid. "/administrator".StartsWith("/admin") 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeaderWriterTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeaderWriterTests.cs
new file mode 100644
index 0000000..85d69c4
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeaderWriterTests.cs
@@ -0,0 +1,112 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using SlpModularCms.Core.Hosting.Security;
+using Xunit;
+
+namespace SlpModularCms.Core.Tests.Hosting.Security;
+
+///
+/// Covers per-header scoping, HSTS gating and the never-overwrite rule.
+///
+///
+/// Tested against a bare rather than through the middleware,
+/// because DefaultHttpContext.Response.OnStarting 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.
+///
+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);
+ }
+
+ ///
+ /// nosniff must reach non-HTML responses in particular — that is the whole point of it.
+ ///
+ [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");
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeadersOptionsValidationTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeadersOptionsValidationTests.cs
new file mode 100644
index 0000000..c8fee0c
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeadersOptionsValidationTests.cs
@@ -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;
+
+///
+/// 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 ValidateOnStart rather than from any code that could be tested in isolation.
+///
+public class SecurityHeadersOptionsValidationTests
+{
+ private static IServiceProvider BuildProvider(Dictionary 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().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>().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
+ {
+ ["SecurityHeaders:PathPolicies:0:PathPrefix"] = "/admin",
+ ["SecurityHeaders:PathPolicies:0:Policy"] = "Stricct"
+ });
+
+ var act = () => Validate(provider);
+
+ act.Should().Throw()
+ .WithMessage("*unknown policy name*");
+ }
+
+ [Fact]
+ public void Validation_ShouldFail_ForAnUnknownDefaultPolicy()
+ {
+ var provider = BuildProvider(new Dictionary
+ {
+ ["SecurityHeaders:DefaultPolicy"] = "Permissive"
+ });
+
+ var act = () => Validate(provider);
+
+ act.Should().Throw()
+ .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
+ {
+ ["SecurityHeaders:AllowedScriptOrigins:0"] = origin
+ });
+
+ var act = () => Validate(provider);
+
+ act.Should().Throw()
+ .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
+ {
+ ["SecurityHeaders:AllowedScriptOrigins:0"] = origin
+ });
+
+ var act = () => Validate(provider);
+
+ act.Should().NotThrow();
+ }
+
+ /// Empty origin lists are a normal state; the policy is simply stricter.
+ [Fact]
+ public void Provider_ShouldComposeBothPolicies_WithNoOriginsConfigured()
+ {
+ var provider = BuildProvider([]);
+
+ var policyProvider = provider.GetRequiredService();
+
+ 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().Get("Stricct");
+
+ act.Should().Throw();
+ }
+}
diff --git a/src/SlpModularCms.Core/Hosting/Security/CspPolicyCatalog.cs b/src/SlpModularCms.Core/Hosting/Security/CspPolicyCatalog.cs
new file mode 100644
index 0000000..5d81095
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Security/CspPolicyCatalog.cs
@@ -0,0 +1,113 @@
+namespace SlpModularCms.Core.Hosting.Security;
+
+///
+/// The three HTML-only header values belonging to one policy.
+///
+public sealed record SecurityHeaderSet(
+ string ContentSecurityPolicy,
+ string FrameOptions,
+ string ReferrerPolicy);
+
+///
+/// The two Content-Security-Policy definitions, in code rather than in configuration.
+///
+///
+/// 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.
+///
+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 KnownPolicies { get; } = [Strict, Relaxed];
+
+ public static bool IsKnownPolicy(string? policy) =>
+ policy is not null &&
+ KnownPolicies.Any(known => known.Equals(policy, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Builds the header set for .
+ ///
+ /// The policy name is not known. Callers reach this only
+ /// past startup validation, so it indicates a code defect rather than a configuration one.
+ public static SecurityHeaderSet Build(
+ string policy,
+ IEnumerable allowedScriptOrigins,
+ IEnumerable 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