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