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