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
6.2 KiB
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 |