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
9.1 KiB
Business Rules — U3 HTTP Security Headers & CSP
Decision Logic
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.