# Domain Entities — U3 HTTP Security Headers & CSP **No persisted entity.** U3 adds no table, no migration and no database column. It introduces one configuration section and two in-code policy definitions. --- ## Concept Relationships ```mermaid graph TD config["SecurityHeaders configuration section"] rules["PathPolicyRule list
path prefix to policy name"] origins["Allowed origin lists
script and connect"] toggle["Enabled flag"] builder["Policy builder"] strict["Strict policy
defined in code"] relaxed["Relaxed policy
defined in code"] composed["Composed policy strings
built once at startup"] middleware["Security headers middleware"] response["HTTP response"] config --> rules config --> origins config --> toggle rules -->|"selects"| builder origins -->|"injected into"| builder builder --> strict builder --> relaxed strict --> composed relaxed --> composed composed -->|"read by"| middleware toggle -->|"gates"| middleware middleware -->|"writes headers to"| 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 output fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000; class config,rules,origins,toggle cfg; class builder,strict,relaxed code; class composed,middleware runtime; class response output; ``` Text alternative: configuration supplies the path-to-policy mapping, the allowed origins and an enable flag; the two policy definitions live in code and are composed into strings once at startup, which the middleware then writes onto responses. **The split is the design** (FU2 = A): configuration decides *where* a policy applies and *which external origins* are permitted. Code decides *what a policy means*. A misconfiguration can therefore misroute a path or omit an origin — both recoverable and both visible — but cannot produce a policy that is subtly wrong. --- ## SecurityHeaders configuration section New section in `appsettings.json`, following the existing Options pattern used by `JwtSettings`, `MasterModule`, `MasterPolling` and `Availability`. | Field | Type | Default | Purpose | |---|---|---|---| | `Enabled` | bool | `true` | Diagnostic escape hatch. Disabling logs a warning (BR-U3-24) | | `PathPolicies` | list of rules | `/admin` → Strict, `/api/v1` → Strict, `/health` → Strict | Ordered path-prefix to policy-name mapping | | `DefaultPolicy` | string | `Relaxed` | Applied when no prefix matches — the public website | | `AllowedScriptOrigins` | string list | empty | Added to `script-src`. The Umami script host | | `AllowedConnectOrigins` | string list | empty | Added to `connect-src` | ### PathPolicyRule | Field | Type | Purpose | |---|---|---| | `PathPrefix` | string | Matched case-insensitively against the start of the request path | | `Policy` | string | Must name a known policy, or startup fails (BR-U3-20) | ### Validation | Aspect | Rule | |---|---| | Unknown policy name | Startup **fails**. No fallback | | Empty origin lists | Valid — the policy is simply stricter (BR-U3-21) | | Empty `PathPolicies` | Valid — everything falls to `DefaultPolicy` | | Origin format | Must be a scheme-and-host origin, without a path | **No Sentry ingest origin is expected**, because U4's tunnel keeps browser error reporting same-origin. `AllowedConnectOrigins` exists for Umami and any future external call, not for Sentry. --- ## Policy definitions (in code, not configuration) ### Strict | Directive | Value | |---|---| | `default-src` | `'self'` | | `script-src` | `'self'` | | `style-src` | `'self' 'unsafe-inline'` | | `img-src` | `'self' data:` | | `font-src` | `'self'` | | `connect-src` | `'self'` + `AllowedConnectOrigins` | | `frame-ancestors` | `'none'` | | `base-uri` | `'self'` | | `form-action` | `'self'` | | `object-src` | `'none'` | Companion headers: `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`. ### Relaxed | Directive | Value | |---|---| | `default-src` | `'self'` | | `script-src` | `'self' 'unsafe-inline'` + `AllowedScriptOrigins` | | `style-src` | `'self' 'unsafe-inline'` | | `img-src` | `'self' data: https:` | | `font-src` | `'self' data: https:` | | `connect-src` | `'self'` + `AllowedConnectOrigins` | | `frame-src` | `'self' https:` | | `frame-ancestors` | `'self'` | | `base-uri` | `'self'` | | `object-src` | `'none'` | Companion headers: `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`. **Both policies always carry** `X-Content-Type-Options: nosniff` and, outside Development, `Strict-Transport-Security: max-age=31536000; includeSubDomains`. --- ## Non-persisted runtime state | Item | Lifetime | Notes | |---|---|---| | Composed policy strings | Singleton, built at startup | Two strings, keyed by policy name. Never rebuilt per request | | Resolved policy name per request | Request scope | Resolved on the way in, used at response start | --- ## Persistence Summary | Question | Answer | |---|---| | New tables? | None | | New migrations? | None | | New configuration sections? | One — `SecurityHeaders` | | Anything written at runtime? | Only HTTP response headers | | Secrets in configuration? | None. Origins are public hostnames | --- ## Environment-Specific Values | Environment | `AllowedScriptOrigins` | HSTS | Notes | |---|---|---|---| | Local | empty | **Not sent** | Umami is not loaded locally, so no origin is needed | | Test | Umami host | Sent | Umami website ID for test | | Production | Umami host | Sent | Umami website ID for production | The Umami script origin is the same host across test and production — only the website ID differs, and that is a frontend build-time value rather than a CSP concern.