# 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 ```mermaid graph TD req["Incoming request"] enabled{"Headers enabled ?"} skip["Continue without headers"] resolve["Resolve policy name
from request path"] hook["Register response-start callback"] next["Continue pipeline"] start["Response starting"] always["Apply always-headers:
X-Content-Type-Options
plus HSTS outside Development"] ishtml{"Content type is HTML ?"} htmlonly["Apply HTML-only headers:
Content-Security-Policy
X-Frame-Options
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 ```mermaid 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 `