NFR Design for U3 and U4. Two decisions the earlier stages had deliberately left open, plus four risks the functional design did not name. OPEN-01 closed: the correlation ID is the W3C trace ID from the ambient Activity, with TraceIdentifier as the fallback. It propagates across the master/slave boundary via traceparent, which TraceIdentifier cannot do at all, and it is the same value ProblemDetails already returns to the client. REF-U3-01 raised: BR-U3-22's Umami-origin startup warning cannot work. The backend never sees VITE_UMAMI_WEBSITE_ID, so the check would either always warn or never warn. Withdrawn from U3 and replaced by a blocking U5 CI gate that compares the frontend build variable against that environment's CSP origins, where both values are visible. Four additions beyond the functional design: - Set-Cookie added to the scrub list; the login response issues the refreshToken there, so scrubbing only the request cookie protects nothing - SetBeforeSendTransaction alongside SetBeforeSend; transactions carry request data too - OnRejected on the rate limiter; today a 429 leaves no trace anywhere - FlushAsync before the migration-failure rethrow, or the one Critical event in the system dies with the process Two traps recorded with tests attached: Sentry groups log events by message template, so interpolated messages make FR-19's rate-based alert rules unimplementable while appearing to work; and DefaultHttpContext.Response .OnStarting is a no-op, so the obvious middleware test asserts nothing. Three values chosen rather than escalated, each one line to change and all three listed for review at the end of U4's pattern document: JSON console outside Development, TracesSampleRate 0.1, tunnel cap 200 KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
7.8 KiB
Logical Components — U3 HTTP Security Headers & CSP
All new types live in SlpModularCms.Core/Hosting/Security/, alongside the existing AdminTokenValidator from U1. Nothing is added to SlpModularCms.Api, so everything here is reachable by SlpModularCms.Core.Tests — deliberately, after U1's Step 11 deviation showed what happens when host-only code needs testing.
Component Wiring
graph TD
subgraph Configuration["Configuration"]
Section["SecurityHeaders section<br/>appsettings per environment"]
Options["SecurityHeadersOptions"]
Validator["IValidateOptions<br/>ValidateOnStart"]
end
subgraph Policy["Policy Composition — startup only"]
Catalog["CspPolicyCatalog<br/>static, in code"]
Provider["ICspPolicyProvider<br/>CspPolicyProvider"]
Frozen["FrozenDictionary<string, SecurityHeaderSet>"]
end
subgraph Request["Request Path — per response"]
Middleware["SecurityHeadersMiddleware"]
Resolver["PathPolicyResolver"]
State["HeaderWriteState<br/>struct-like state object"]
Writer["SecurityHeaderWriter<br/>static, pure"]
end
subgraph Host["Host Wiring"]
AddExt["AddCmsSecurityHeaders"]
UseExt["UseCmsSecurityHeaders"]
Logger["ILogger — startup origins,<br/>disabled warning"]
end
Response["HTTP response headers"]
Section --> Options
Options --> Validator
Options --> Provider
Options --> Resolver
Catalog --> Provider
Provider --> Frozen
AddExt --> Options
AddExt --> Provider
AddExt --> Resolver
UseExt --> Middleware
UseExt --> Logger
Middleware --> Resolver
Middleware --> Provider
Middleware --> State
State --> Writer
Writer --> Response
classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef code fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef runtime fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef host fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
classDef out fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class Section,Options,Validator cfg;
class Catalog,Provider,Frozen,Writer code;
class Middleware,Resolver,State runtime;
class AddExt,UseExt,Logger host;
class Response out;
Text alternative: configuration binds to validated options that feed both the policy provider and the path resolver at startup; the provider composes the in-code catalog into a frozen dictionary of header sets; per response the middleware resolves a policy name, registers a response-start callback carrying a state object, and a static writer applies the headers.
Component Responsibilities
| Component | Lifetime | Responsibility | Pattern |
|---|---|---|---|
SecurityHeadersOptions |
Options singleton | Binds Enabled, DefaultPolicy, PathPolicies, AllowedScriptOrigins, AllowedConnectOrigins |
1, 11 |
PathPolicyRule |
Record | One PathPrefix → Policy pair |
3 |
CspPolicyCatalog |
Static | The two policy definitions. Pure function of origin lists → directive list | 2 |
SecurityHeaderSet |
Record | The three HTML-only header values for one policy | 2 |
ICspPolicyProvider / CspPolicyProvider |
Singleton | Composes and caches header sets once; one lookup per response | 2 |
PathPolicyResolver |
Singleton | Ordered, segment-aware prefix match → policy name | 3 |
SecurityHeadersMiddleware |
Per-request instance, singleton delegate | Gate on Enabled, resolve the policy, register the response-start callback |
4 |
SecurityHeaderWriter |
Static | Per-header scoping, HSTS gating, never-overwrite. All decision logic | 5, 6 |
SecurityHeadersExtensions |
Static | AddCmsSecurityHeaders / UseCmsSecurityHeaders, startup logging |
9, 10 |
Why SecurityHeaderWriter is static and separate from the middleware: it is the only component whose behaviour is worth exhaustive testing, and DefaultHttpContext cannot drive it through the middleware (Pattern 5). Splitting it makes the interesting part testable with new HeaderDictionary() and leaves the middleware as glue with nothing to get wrong except ordering — which is verified at Build and Test.
Why PathPolicyResolver is a class rather than a method on the provider: it answers a different question (which policy) from the provider (what the policy contains), and the /administrator versus /admin trap deserves its own test class rather than being buried in provider tests.
DI Registration Order
Inside AddCmsSecurityHeaders(configuration):
1. services.AddOptions<SecurityHeadersOptions>()
.Bind(configuration.GetSection("SecurityHeaders"))
.Validate(...) // unknown policy name, origin format
.ValidateOnStart()
2. services.AddSingleton<ICspPolicyProvider, CspPolicyProvider>()
3. services.AddSingleton<PathPolicyResolver>()
Called from Program.cs next to the other AddCms* calls, before module registration. Order within the file does not matter — nothing here is overridable by a module, unlike Data Protection in U2.
UseCmsSecurityHeaders() is called first inside UseExceptionHandler(); see Pattern 10. That position does matter, in both directions.
Both Hosts
SlpModularCms.Api and SlpModularCms.Api.Slave both call AddCmsSecurityHeaders and UseCmsSecurityHeaders. The slave host serves no admin SPA and no public website today, 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 slave's PathPolicies defaults are identical; the paths that do not exist there simply never match.
NFR Coverage Traceability
| NFR / Rule | Pattern | Component |
|---|---|---|
| NFR-01 — no server configuration | Headers emitted in-process | SecurityHeadersMiddleware, SecurityHeaderWriter |
| NFR-03 — startup cost | Compose twice at startup, never per request | CspPolicyProvider + FrozenDictionary |
| NFR-06 — testability | Decision logic in pure static functions | SecurityHeaderWriter, CspPolicyCatalog, PathPolicyResolver |
| SECURITY-04 | All five headers; CSP on every HTML path | SecurityHeaderWriter, CspPolicyCatalog |
| SECURITY-11 | CSP as a second layer behind output escaping | CspPolicyCatalog |
| SECURITY-15 — fail closed | ValidateOnStart; never throws per request |
Pattern 1, Pattern 7 |
| BR-U3-01…03 — per-header scope | Explicit sendHsts + content-type parse |
SecurityHeaderWriter |
| BR-U3-04 — never overwrite | TryAdd rather than the indexer |
SecurityHeaderWriter |
| BR-U3-05 — write at response start | OnStarting(callback, state) with a static delegate |
SecurityHeadersMiddleware |
| BR-U3-06, BR-U3-09 — position | Inside the exception handler, before static files | UseCmsSecurityHeaders call site |
| BR-U3-07 — never throw | Catch-and-log inside the callback | SecurityHeadersMiddleware |
| BR-U3-10…19 — policy content | Two policies in code, origins from configuration | CspPolicyCatalog |
| BR-U3-20 — unknown name fatal | ValidateOnStart |
Pattern 1 |
| BR-U3-22 | Refined — REF-U3-01, moved to the U5 CI gate | Carried to U5 |
| BR-U3-23, BR-U3-24 — startup logging | Logged from the Use extension |
SecurityHeadersExtensions |
Carried Forward
| Item | To | Why |
|---|---|---|
| REF-U3-01 — Umami origin drift gate | U5 CI Workflow | The backend cannot see VITE_UMAMI_WEBSITE_ID; the CI job can see both sides and must fail on drift |
| Pipeline-order verification | Build and Test | A misordered registration passes every unit test and serves the whole website without a CSP |
Header presence on a real 503 and a real static asset |
Build and Test | Needs a running host with a real response feature |