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