# 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 `