From a1225484542e7c925c172d552e06b888a383532d Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 28 Jul 2026 10:58:45 +0200 Subject: [PATCH] Sends the security headers from the application instead of the proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U3. These headers normally come from nginx, but the deployment target does not allow server configuration, so the application emits them itself. That changes the failure mode: a bad nginx config fails loudly at reload, while a middleware that never runs sends nothing and says nothing. Two policies, defined in code. Strict for /admin, /api/v1 and /health; relaxed for the public website, which is authored elsewhere by someone who has never seen this policy. Configuration decides where a policy applies and which external origins are permitted; it cannot invent a policy that is subtly permissive. script-src 'self' under Strict has no 'unsafe-inline' and no 'unsafe-eval', asserted by a test so that loosening it means deleting a test that says why. style-src does carry 'unsafe-inline' and cannot not: Radix positions its overlays with inline style attributes, which nonces cannot reach at all. Two traps handled explicitly. StartsWithSegments rather than string StartsWith, because "/administrator".StartsWith("/admin") is true and a public page would silently lose its inline scripts with no server-side trace. And all decision logic sits in a static writer rather than in the middleware, because DefaultHttpContext.Response.OnStarting is a no-op — the obvious middleware test observes nothing and an assertion that nothing was written passes for entirely the wrong reason. An unknown policy name fails ValidateOnStart, so the process exits rather than quietly serving /admin under the relaxed policy. Origin format is validated too, beyond what the design asked: a CSP source list silently ignores a malformed source, so a URL with a path would look configured and block the script anyway. BR-U3-22's Umami startup warning is withdrawn (REF-U3-01) — the backend never sees VITE_UMAMI_WEBSITE_ID. It becomes a blocking CI gate in U5. Build 0 errors; 315 tests pass, up from 253. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw --- .../code/generation-summary.md | 87 +++++++++++ src/SlpModularCms.Api.Slave/Program.cs | 10 ++ src/SlpModularCms.Api.Slave/appsettings.json | 10 ++ src/SlpModularCms.Api/Program.cs | 9 ++ src/SlpModularCms.Api/appsettings.json | 11 ++ .../Hosting/Security/CspPolicyCatalogTests.cs | 147 ++++++++++++++++++ .../Security/PathPolicyResolverTests.cs | 99 ++++++++++++ .../Security/SecurityHeaderWriterTests.cs | 112 +++++++++++++ .../SecurityHeadersOptionsValidationTests.cs | 129 +++++++++++++++ .../Hosting/Security/CspPolicyCatalog.cs | 113 ++++++++++++++ .../Hosting/Security/CspPolicyProvider.cs | 45 ++++++ .../Hosting/Security/PathPolicyResolver.cs | 64 ++++++++ .../Hosting/Security/SecurityHeaderWriter.cs | 92 +++++++++++ .../Security/SecurityHeadersExtensions.cs | 92 +++++++++++ .../Security/SecurityHeadersMiddleware.cs | 104 +++++++++++++ .../Security/SecurityHeadersOptions.cs | 71 +++++++++ 16 files changed, 1195 insertions(+) create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/code/generation-summary.md create mode 100644 src/SlpModularCms.Core.Tests/Hosting/Security/CspPolicyCatalogTests.cs create mode 100644 src/SlpModularCms.Core.Tests/Hosting/Security/PathPolicyResolverTests.cs create mode 100644 src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeaderWriterTests.cs create mode 100644 src/SlpModularCms.Core.Tests/Hosting/Security/SecurityHeadersOptionsValidationTests.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/CspPolicyCatalog.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/CspPolicyProvider.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/PathPolicyResolver.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/SecurityHeaderWriter.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/SecurityHeadersExtensions.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/SecurityHeadersMiddleware.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/SecurityHeadersOptions.cs 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