Designs the security headers and observability units

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
This commit is contained in:
2026-07-28 00:01:04 +02:00
co-authored by Claude Opus 5
parent 5f3eda2680
commit 357d395629
13 changed files with 2471 additions and 0 deletions
@@ -0,0 +1,226 @@
# Business Logic Model — U3 HTTP Security Headers & CSP
**Unit**: U3 HTTP Security Headers & CSP
**Requirements**: FR-18
---
## 1. Scope of the Logic
Normally these headers come from nginx or IIS configuration. NFR-01 forbids relying on server configuration, so the application must supply them itself — which turns a configuration file into request-processing logic with three decisions per response:
1. **Which policy** applies to this request path
2. **Which headers** apply to this response, based on its content type
3. **Whether** headers apply at all in this environment
---
## 2. Header Application Flow
```mermaid
graph TD
req["Incoming request"]
enabled{"Headers enabled ?"}
skip["Continue without headers"]
resolve["Resolve policy name<br/>from request path"]
hook["Register response-start callback"]
next["Continue pipeline"]
start["Response starting"]
always["Apply always-headers:<br/>X-Content-Type-Options<br/>plus HSTS outside Development"]
ishtml{"Content type is HTML ?"}
htmlonly["Apply HTML-only headers:<br/>Content-Security-Policy<br/>X-Frame-Options<br/>Referrer-Policy"]
done["Response sent"]
req --> enabled
enabled -->|no| skip
enabled -->|yes| resolve
resolve --> hook
hook --> next
next --> start
start --> always
always --> ishtml
ishtml -->|yes| htmlonly
ishtml -->|no| done
htmlonly --> done
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef neutral fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class req,start entry;
class enabled,ishtml decision;
class resolve,hook,next,always,htmlonly step;
class skip,done neutral;
```
Text alternative: the policy for the path is resolved when the request arrives, but headers are written at response start — because the content type, which decides whether the HTML-only headers apply, is not known any earlier.
**Why the work is split across two moments**: path matching happens once per request, cheaply, before the pipeline continues. Content-type inspection can only happen at response start. Doing both at response start would repeat path matching on every static asset; doing both early would force an all-or-nothing choice on header scope.
**Why registration must precede static files**: static-file middleware short-circuits the pipeline. Anything registered after it never observes a static response — and static responses are exactly what the public website consists of.
---
## 3. Per-Header Scoping
Per FU1 = A, scope is decided per header rather than uniformly.
| Header | Applies to | Reason |
|---|---|---|
| `X-Content-Type-Options: nosniff` | **All** responses | Exists specifically to stop MIME-sniffing of non-HTML resources. Restricting it to HTML would remove it exactly where it does its job |
| `Strict-Transport-Security` | **All** responses, **outside Development only** | A host-level transport directive, not a page directive. A visitor whose first request is an asset would otherwise never receive it |
| `Content-Security-Policy` | HTML responses only | Meaningless on an image or a script file |
| `X-Frame-Options` | HTML responses only | Governs framing of documents |
| `Referrer-Policy` | HTML responses only | Governs navigation and resource referrers from a document |
**HSTS and Development** (Q3 = A): browsers remember HSTS per host, for a long time, and `localhost` is shared with every other local project. Sending it during development would affect unrelated work and is awkward to undo. Every other header **does** apply in Development, so a CSP violation surfaces while developing rather than in production.
---
## 4. Policy Selection
```mermaid
graph TD
path["Request path"]
admin{"Starts with /admin ?"}
api{"Starts with /api/v1 ?"}
health{"Is /health ?"}
strict["Strict policy"]
relaxed["Relaxed policy"]
path --> admin
admin -->|yes| strict
admin -->|no| api
api -->|yes| strict
api -->|no| health
health -->|yes| strict
health -->|no| relaxed
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef strictnode fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef relaxednode fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
class path entry;
class admin,api,health decision;
class strict strictnode;
class relaxed relaxednode;
```
Text alternative: paths under `/admin`, `/api/v1` and `/health` get the strict policy; everything else — the public website and the placeholder page — gets the relaxed policy.
The mapping itself is configuration (FU2 = A), so a path can be added without code changes. The two policies are defined in code, so a misconfiguration can misroute a path but cannot invent a broken policy.
---
## 5. What Each Policy Permits
### Strict — `/admin`, `/api/v1`, `/health`
| Directive | Value | Reason |
|---|---|---|
| `default-src` | `'self'` | Deny by default |
| `script-src` | `'self'` | **No `'unsafe-inline'`, no `'unsafe-eval'`.** This is where XSS risk actually lives, and it stays closed |
| `style-src` | `'self' 'unsafe-inline'` | Required — see below |
| `img-src` | `'self' data:` | `data:` covers inlined icons in the built bundle |
| `font-src` | `'self'` | Fonts ship with the bundle |
| `connect-src` | `'self'` + configured origins | Same-origin API. The Sentry tunnel (U4) keeps error reporting same-origin too |
| `frame-ancestors` | `'none'` | Matches `X-Frame-Options: DENY` for modern browsers |
| `base-uri` | `'self'` | Prevents base-tag injection redirecting relative URLs |
| `form-action` | `'self'` | Prevents form hijacking |
| `object-src` | `'none'` | No plugins |
**`style-src 'unsafe-inline'` — why it is unavoidable** (FU1 = A):
Radix UI positions dropdowns, dialogs and selects by writing inline `style` attributes such as `style="transform: translate(...)"`, recalculated per click, viewport and scroll position.
CSP nonces apply only to `<style>` and `<script>` **elements** — inline `style` **attributes** are outside their reach entirely. The only mechanisms that can permit them are `'unsafe-inline'`, or `'unsafe-hashes'` with a hash per exact attribute value, and those values are dynamic so no finite set exists. There is therefore no nonce- or hash-based variant that leaves the admin UI functional.
The exception is contained: it applies to `style-src` only. Injected CSS can restyle a page, but `script-src 'self'` still prevents execution of injected script, which is the actual escalation path.
### Relaxed — the public website
Per FU2 = C, the website receives an **enforcing** CSP rather than none, so SECURITY-04 is satisfied on every HTML-serving path.
| Directive | Value | Reason |
|---|---|---|
| `default-src` | `'self'` | Deny by default |
| `script-src` | `'self' 'unsafe-inline'` | A website author may use inline scripts and has never seen this policy |
| `style-src` | `'self' 'unsafe-inline'` | Same |
| `img-src` | `'self' data: https:` | Images from any HTTPS source — commonplace on a marketing site |
| `font-src` | `'self' data: https:` | Web fonts from any HTTPS source |
| `connect-src` | `'self'` + configured origins | Includes the Umami origin when configured |
| `frame-src` | `'self' https:` | Embeds such as maps and video |
| `frame-ancestors` | `'self'` | Matches `X-Frame-Options: SAMEORIGIN` |
| `base-uri` | `'self'` | Retained — cheap and breaks nothing |
| `object-src` | `'none'` | Retained |
**What "relaxed" deliberately still blocks**: an external `script-src`. A website author who needs a third-party script must have its origin added to configuration — which the website contract (FR-09) documents. That keeps the policy from being a rubber stamp while still not surprising anyone with a broken layout.
**Note on `'unsafe-inline'` and external scripts**: when a source list contains `'unsafe-inline'`, browsers honour it *and* the listed origins. Adding an origin therefore does not silently disable inline scripts.
---
## 6. Frame Options Per Policy
Per Q4 = A, this refines FR-18, which specified `DENY` globally without accounting for the public website:
| Path | `X-Frame-Options` | `frame-ancestors` |
|---|---|---|
| `/admin`, `/api/v1`, `/health` | `DENY` | `'none'` |
| Public website | `SAMEORIGIN` | `'self'` |
The admin UI stays maximally protected against click-jacking. A customer embedding one of their own pages in an iframe on their own site is not broken by a policy they never chose.
Both headers are emitted because they overlap rather than replace: `X-Frame-Options` covers older browsers, `frame-ancestors` is the modern equivalent and takes precedence where supported.
---
## 7. Startup Validation
```mermaid
graph TD
boot["Startup"]
unknown{"Every configured policy name<br/>is a known policy ?"}
fail["Throw: process does not start"]
build["Build both policy strings once"]
monitoring{"Umami website ID configured<br/>but its origin missing from CSP ?"}
warn["Log a warning naming the missing origin"]
log["Log which origins are permitted"]
ready["Ready to serve"]
boot --> unknown
unknown -->|no| fail
unknown -->|yes| build
build --> monitoring
monitoring -->|yes| warn
monitoring -->|no| log
warn --> log
log --> ready
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef warnnode fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class boot entry;
class unknown,monitoring decision;
class build,log,ready step;
class warn warnnode;
class fail bad;
```
Text alternative: an unknown policy name stops the process at startup rather than degrading per request, and a configured Umami website ID whose origin is missing from the CSP produces a warning — catching the case where analytics appears configured but is silently blocked.
**Why an unknown policy name is fatal** (fail closed, SECURITY-15): the alternative is falling back to something, and every fallback is either wrong or silently permissive. A typo in a path-policy mapping should stop a deployment, not quietly serve `/admin` under the relaxed policy.
**Why the monitoring warning covers Umami only** (Q5 = C): the question anticipated needing a Sentry ingest origin in `connect-src`. U4 Q1 = A chose a **tunnel through the API**, so browser error reports go to the application's own origin and `connect-src 'self'` already covers them. There is no Sentry origin to forget, so the warning would have nothing to check. Umami's script is still loaded from its own origin, so that check remains meaningful.
**Policies are built once at startup**, then reused. Composing a CSP per response would be wasteful on a workload that is mostly static files.
---
## 8. Interaction With the Placeholder Page
The built-in placeholder (U1) is served at `/`, so it receives the **relaxed** policy. It is a self-contained HTML document with a `<style>` block and no scripts, which the relaxed policy permits via `style-src 'unsafe-inline'`.
Worth stating because it is easy to overlook: the placeholder is the one HTML document this repository serves at the website path, so if the relaxed policy were ever tightened, it is the first thing that would break — and it would break on a fresh installation, which is the worst moment for a confusing failure.
@@ -0,0 +1,137 @@
# Business Rules — U3 HTTP Security Headers & CSP
---
## Decision Logic
```mermaid
graph TD
start["Response starting"]
exists{"Header already set<br/>by something else ?"}
leave["Leave it untouched"]
dev{"Environment is Development<br/>and header is HSTS ?"}
skiphsts["Skip HSTS"]
always{"Header is nosniff or HSTS ?"}
apply["Apply"]
html{"Content type is HTML ?"}
skiphtml["Skip: not an HTML response"]
start --> exists
exists -->|yes| leave
exists -->|no| dev
dev -->|yes| skiphsts
dev -->|no| always
always -->|yes| apply
always -->|no| html
html -->|yes| apply
html -->|no| skiphtml
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef neutral fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class start entry;
class exists,dev,always,html decision;
class apply good;
class leave,skiphsts,skiphtml neutral;
```
Text alternative: an already-present header is never overwritten; HSTS is skipped in Development; `nosniff` and HSTS apply to every response while the remaining three apply only to HTML responses.
---
## Applicability Rules
| ID | Rule |
|---|---|
| **BR-U3-01** | `X-Content-Type-Options: nosniff` is applied to **every** response. |
| **BR-U3-02** | `Strict-Transport-Security` is applied to **every** response **except in Development**. |
| **BR-U3-03** | `Content-Security-Policy`, `X-Frame-Options` and `Referrer-Policy` are applied **only** to responses whose content type is HTML. |
| **BR-U3-04** | A header already present on the response is never overwritten. |
| **BR-U3-05** | Headers are written at response start, not before the pipeline continues, because the content type is unknown earlier. |
| **BR-U3-06** | The middleware is registered **before** static-file middleware, which short-circuits the pipeline. |
| **BR-U3-07** | Header application never throws into the response path. A configuration error is a startup failure, not a per-request one. |
| **BR-U3-08** | Every header except HSTS applies in Development, so a CSP violation surfaces during development. |
| **BR-U3-09** | Headers apply to error responses too — the middleware sits inside the exception handler. |
**Rationale for BR-U3-01**: `nosniff` exists to stop a browser guessing the type of a **non-HTML** resource. An uploaded `.txt` or `.svg` interpreted as HTML or JavaScript is the attack it prevents, so restricting it to HTML would remove it precisely where it works.
**Rationale for BR-U3-04**: a component that deliberately set a header — a download endpoint setting its own `Content-Disposition`-adjacent policy, for example — knows something this middleware does not. Overwriting would be silently destructive.
---
## Policy Content Rules
| ID | Rule |
|---|---|
| **BR-U3-10** | Exactly two policies exist, defined **in code**: `Strict` and `Relaxed`. |
| **BR-U3-11** | Path-to-policy assignment and allowed origins come from **configuration**, so a path or origin can be added without a code change. |
| **BR-U3-12** | `Strict` applies to `/admin`, `/api/v1` and `/health`. `Relaxed` is the default for everything else. |
| **BR-U3-13** | `Strict` sets `script-src 'self'`**no `'unsafe-inline'` and no `'unsafe-eval'`**. This must not be relaxed. |
| **BR-U3-14** | `Strict` sets `style-src 'self' 'unsafe-inline'`. Documented exception, unavoidable — see below. |
| **BR-U3-15** | `Relaxed` is **enforcing**, not report-only, so SECURITY-04 is satisfied on every HTML-serving path. |
| **BR-U3-16** | `Relaxed` permits inline scripts and styles, and images, fonts and frames from any HTTPS origin — but **not** external script origins. |
| **BR-U3-17** | Both policies set `object-src 'none'` and `base-uri 'self'`. |
| **BR-U3-18** | Policy strings are composed once at startup and reused. |
| **BR-U3-19** | `X-Frame-Options` is `DENY` under `Strict` and `SAMEORIGIN` under `Relaxed`, with `frame-ancestors` set to match. |
**Rationale for BR-U3-14 — the one exception, and why it is not negotiable**: Radix UI positions dropdowns, dialogs and selects using inline `style` attributes whose values are recomputed per click, viewport and scroll position. CSP nonces apply only to `<style>` and `<script>` *elements*; inline `style` *attributes* are outside their scope entirely. The alternatives are `'unsafe-inline'` or `'unsafe-hashes'` with a hash per exact value — and the values are dynamic, so no finite set exists. No nonce- or hash-based variant leaves the admin UI functional.
The exception is bounded to `style-src`. Injected CSS can restyle a page; it cannot execute, because `script-src 'self'` still holds. The escalation path stays closed.
**Rationale for BR-U3-13**: this is the directive that matters. If it is ever relaxed, the value of the whole policy collapses — so it is stated as a rule rather than left as a default.
**Rationale for BR-U3-16**: "relaxed" must not mean "absent". Blocking external script origins keeps a genuine boundary while not surprising a website author with a broken layout. A third-party script requires adding its origin to configuration, which FR-09's contract documents.
---
## Startup Validation Rules
| ID | Rule |
|---|---|
| **BR-U3-20** | A configured policy name that is not a known policy causes startup to **fail**. No fallback. |
| **BR-U3-21** | Empty origin lists are a normal state. The policy simply becomes stricter. |
| **BR-U3-22** | When a Umami website ID is configured but its script origin is absent from the allowed origins, a **warning** is logged at startup naming the missing origin. |
| **BR-U3-23** | The permitted origins are logged at startup at informational level, so the log records what was actually allowed. |
| **BR-U3-24** | Headers can be disabled wholesale by configuration, for diagnosis. Disabling is logged as a warning. |
**Rationale for BR-U3-20** (fail closed, SECURITY-15): any fallback is either wrong or silently permissive. A typo in a path mapping should stop a deployment rather than quietly serve `/admin` under the relaxed policy.
**Rationale for BR-U3-22**: this catches a failure that is otherwise invisible — analytics configured, appearing to work, and silently blocked by the browser. Note it deliberately does **not** check for a Sentry origin: U4's tunnel keeps error reporting same-origin, so there is no Sentry origin to forget.
**Rationale for BR-U3-24**: a diagnostic escape hatch is worth having, but silently disabled security headers are worse than none, so switching them off announces itself.
---
## Error and Edge-Case Scenarios
| Scenario | Expected behaviour |
|---|---|
| Request for `/admin/dashboard`, HTML response | All five headers; `Strict` policy; `X-Frame-Options: DENY` |
| Request for `/admin/assets/app.js` | `nosniff` and HSTS only — not an HTML response |
| Request for `/`, website HTML | All five headers; `Relaxed` policy; `SAMEORIGIN` |
| Request for `/` on a fresh install, placeholder page | `Relaxed` policy. The placeholder's `<style>` block is permitted by `style-src 'unsafe-inline'` |
| Request for `/api/v1/Users`, JSON response | `nosniff` and HSTS only. `Strict` policy resolved but the CSP is not written to a JSON response |
| `503` from the availability gate, JSON `ProblemDetails` | `nosniff` and HSTS. The middleware sits before the gate, so the response still carries them |
| Unhandled exception, `ProblemDetails` response | Headers applied — the middleware is inside the exception handler |
| `304 Not Modified` | `nosniff` and HSTS. No body, so the HTML-only headers do not apply |
| Redirect from `/admin` to `/admin/` | `nosniff` and HSTS. A redirect has no HTML body |
| Running in Development | Every header except HSTS |
| No origins configured | Policies composed without them; stricter, and logged |
| Umami ID configured, origin missing | Warning at startup naming the origin |
| Configured policy name is `Stricct` | Startup fails with the unknown name |
| Headers disabled by configuration | No headers, and a warning logged |
| A downstream component already set `Referrer-Policy` | Its value is kept |
---
## Security Compliance for U3
| Rule | Status | Notes |
|---|---|---|
| SECURITY-04 | **Compliant** | All five headers present. HSTS `max-age` is one year with `includeSubDomains`. A CSP applies to **every** HTML-serving path, including the public website (BR-U3-15) — so no deviation is needed. `'unsafe-inline'` appears only in `style-src` under `Strict`, documented in BR-U3-14; `Relaxed` additionally permits inline scripts, documented in BR-U3-16 as a deliberate choice for content this repository does not author |
| SECURITY-09 | Compliant | No internal detail is exposed by any header |
| SECURITY-11 | **Improved** | Defence in depth: the CSP is a second layer behind output escaping, on both the admin UI and the website |
| SECURITY-15 | Compliant | Fails closed at startup on an unknown policy; never throws per request |
**No deviation recorded.** The earlier answer of "no CSP on the public website" was superseded by FU2 = C, which keeps SECURITY-04 satisfied outright rather than accepting a documented exception.
@@ -0,0 +1,152 @@
# 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<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.