Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/nfr-design/logical-components.md
T
SluijsensandClaude Opus 5 5102f8668b Settles how the headers and observability units get built
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
2026-07-28 10:25:11 +02:00

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&lt;string, SecurityHeaderSet&gt;"]
    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 PathPrefixPolicy 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