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` |