Files
SluijsensandClaude Opus 5 357d395629 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
2026-07-28 00:01:04 +02:00

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.