Records the functional design for the two remaining application units, before any of their code exists. Security headers have to come from the application, because relying on nginx or IIS configuration is exactly what this deployment model rules out. Strict applies to /admin, /api/v1 and /health; a relaxed policy applies to the public website, which this repository does not author. The strict policy needs style-src 'unsafe-inline'. That is not a shortcut: Radix positions dropdowns and dialogs with inline style attributes recalculated per click and scroll position, and CSP nonces apply only to style elements, never to style attributes. No nonce- or hash-based variant leaves the admin UI working. The exception is bounded to styles — script-src stays closed, which is where XSS actually lives. The website's policy is enforcing rather than absent, so every HTML-serving path carries a CSP and no exception has to be recorded. It still blocks external script origins, so it remains a real boundary. HSTS is skipped in development: browsers remember it per host and localhost is shared with unrelated projects. Every other header applies locally, so a CSP violation surfaces while developing. For observability, browser error reports tunnel through the API rather than going to Sentry directly. Ad blockers block Sentry domains, which loses errors precisely for the users most likely to have browser oddities. The tunnel forwards only to the host derived from the configured DSN — a caller-supplied destination would turn an anonymous endpoint into a request-forgery primitive. Two consequences of the chosen options are recorded rather than left implicit: Enabling SendDefaultPii attaches request headers, and this application carries two standing credentials in them. Besides the refreshToken cookie, X-Master-Api-Key would have been sent to a third party on every error raised during a master/slave call. The scrub list removes the whole Cookie header, Authorization, X-Master-Api-Key and the request body. Console logging at Information plus structured logging to Sentry would, taken literally, mean one Sentry event per request — exhausting the free plan within hours and burying real errors in request noise. The thresholds are split: console keeps Information, Sentry takes warnings and above as events with Information as breadcrumbs, so every event arrives carrying the trail that led to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
5.8 KiB
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
graph TD
config["SecurityHeaders configuration section"]
rules["PathPolicyRule list<br/>path prefix to policy name"]
origins["Allowed origin lists<br/>script and connect"]
toggle["Enabled flag"]
builder["Policy builder"]
strict["Strict policy<br/>defined in code"]
relaxed["Relaxed policy<br/>defined in code"]
composed["Composed policy strings<br/>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.