Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/functional-design/business-logic-model.md
T
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

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:

  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

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.