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

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.