Sends the security headers from the application instead of the proxy

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