# 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 ```mermaid graph TD subgraph Configuration["Configuration"] Section["SecurityHeaders section
appsettings per environment"] Options["SecurityHeadersOptions"] Validator["IValidateOptions
ValidateOnStart"] end subgraph Policy["Policy Composition — startup only"] Catalog["CspPolicyCatalog
static, in code"] Provider["ICspPolicyProvider
CspPolicyProvider"] Frozen["FrozenDictionary<string, SecurityHeaderSet>"] end subgraph Request["Request Path — per response"] Middleware["SecurityHeadersMiddleware"] Resolver["PathPolicyResolver"] State["HeaderWriteState
struct-like state object"] Writer["SecurityHeaderWriter
static, pure"] end subgraph Host["Host Wiring"] AddExt["AddCmsSecurityHeaders"] UseExt["UseCmsSecurityHeaders"] Logger["ILogger — startup origins,
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() .Bind(configuration.GetSection("SecurityHeaders")) .Validate(...) // unknown policy name, origin format .ValidateOnStart() 2. services.AddSingleton() 3. services.AddSingleton() ``` 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 |