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
12 KiB
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:
- Which policy applies to this request path
- Which headers apply to this response, based on its content type
- Whether headers apply at all in this environment
2. Header Application Flow
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
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
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.