# Content Security Policy Strategy

Authoritative source: [`docs/adr/018-csp-enforcement-strategy.md`](../adr/018-csp-enforcement-strategy.md).
Implementation: [`lib/security/csp.ts`](../../lib/security/csp.ts) and
[`proxy.ts`](../../proxy.ts). Structural validation: `npm run check:csp`
(bundled in `check:portability`).

## Where the policy lives

CSP is **per-request** and built from a single helper:

| File | Role |
|---|---|
| `lib/security/csp.ts` | Pure builder: `generateNonce()`, `buildCspHeader({ nonce, isDev })`, `buildReportingEndpointsHeader()`, mode resolution. No Next.js deps, no side effects. |
| `lib/security/csp-report.ts` | Pure normalizer: `normalizeCspReportBody(raw)` parses both legacy (`application/csp-report`) and Reporting API (`application/reports+json`) payload shapes. `isLowSignalReport()` classifies browser-extension noise for downgraded logging. |
| `lib/security/index.ts` | Module barrel. All external imports use `@/lib/security`. |
| `proxy.ts` (Next.js 16 proxy/middleware) | Generates one nonce per request, injects `x-nonce` into the forwarded request headers, sets the CSP + `Reporting-Endpoints` response headers, and returns the response. Matcher explicitly excludes `/api/csp-report` so unauthenticated reports survive the auth redirect. |
| `app/api/csp-report/route.ts` | Collector endpoint. Unauthenticated POST, 64 KB body limit, 50 records/request cap. Emits structured `csp.violation.{enforced,report_only,low_signal,rejected_*,batch_clipped}` events via `@/lib/logger`. |
| `next.config.mjs` | Ships only the *static* security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). **No longer ships CSP.** |
| `scripts/check-csp.mjs` | Structural validator — fails CI if `'unsafe-inline'` sneaks back onto script-src, `'strict-dynamic'` disappears, a required directive is missing, or the `report-uri` / `report-to` wiring drifts from `/api/csp-report` + `csp-endpoint`. |

## Phased rollout

```
Phase 1 ──── report-only (today, default) ──── review ──── Phase 2 enforced (CSP_ENFORCE=1)
```

`CSP_ENFORCE=1` flips the emitted header from
`Content-Security-Policy-Report-Only` to `Content-Security-Policy`. No code
change — a single env variable read in `getCspMode()`. The policy itself is
identical in both modes; only the header name changes.

## Nonce flow

```
┌─────────────────────────────────────────────────────────────────────┐
│ 1. proxy.ts runs for every matching request                         │
│    ↓                                                                │
│ 2. generateNonce() → 16 random bytes → base64 (~24 chars)           │
│    ↓                                                                │
│ 3. request header `x-nonce` is forwarded into the SSR pipeline      │
│    (via NextResponse.next({ request: { headers: forwarded } })).    │
│    Next.js App Router auto-applies `x-nonce` to the inline bootstrap│
│    scripts it injects during SSR.                                   │
│    ↓                                                                │
│ 4. buildCspHeader({ nonce, isDev }) composes the policy string      │
│    ↓                                                                │
│ 5. response header `Content-Security-Policy[-Report-Only]` is set   │
│    on the response. Any stale CSP header from next.config.mjs is    │
│    explicitly deleted first.                                        │
└─────────────────────────────────────────────────────────────────────┘
```

If a future feature needs an explicit `<Script>` tag, read the nonce
server-side and pass it through:

```tsx
import { headers } from 'next/headers'

export default async function Layout() {
  const nonce = (await headers()).get('x-nonce') ?? undefined
  return <Script nonce={nonce} src="/whatever.js" strategy="beforeInteractive" />
}
```

## Current policy

All `{nonce}` values are per-request, base64, ~24 chars.

```
default-src      'self'
base-uri         'self'
object-src       'none'
frame-ancestors  'none'
form-action      'self'
img-src          'self' data: blob: https://*.supabase.co https://*.supabase.in
font-src         'self' data:
style-src        'self' 'unsafe-inline'
script-src       'self' 'nonce-{nonce}' 'strict-dynamic' ['unsafe-eval' only when NODE_ENV !== 'production']
connect-src      'self' https://*.supabase.co wss://*.supabase.co https://*.vercel-insights.com
worker-src       'self' blob:
manifest-src     'self'
report-uri       /api/csp-report                ← legacy fallback (Safari, older Firefox)
report-to        csp-endpoint                   ← modern Reporting API
```

Paired response header (also set by `proxy.ts`):

```
Reporting-Endpoints: csp-endpoint="/api/csp-report"
```

### What changed in R-02

| Token | Before | After |
|---|---|---|
| `script-src 'unsafe-inline'` | present | **removed** |
| `script-src 'unsafe-eval'` | present (all envs) | **removed in production**; retained in `NODE_ENV !== 'production'` only (HMR / React Refresh) |
| `script-src 'nonce-...'` | absent | **present** (per-request, via proxy.ts) |
| `script-src 'strict-dynamic'` | absent | **present** |
| `style-src 'unsafe-inline'` | present | still present — documented residual |

### Residuals (documented, not silent)

1. **`style-src 'unsafe-inline'`** — The app uses inline style attributes
   (`style={{...}}` in JSX), most importantly `app/layout.tsx` to inject
   per-tenant branding CSS variables. CSP nonces cover `<style>` tags but
   **not** style attributes on HTML elements. Closing this requires moving
   branding CSS variables into a single nonced `<style>` tag in `<head>` and
   sweeping the 5 files with inline `style={{...}}` usage
   (`app/owner/tenants/page.tsx`, `app/owner/tenants/[tenantId]/page.tsx`,
   `app/owner/provisioning/[jobId]/page.tsx`,
   `components/takt-diagram/cycle-detail-chart.tsx`,
   `components/offline/sync-indicator.tsx`). Not in R-02 scope.

2. **Dev-only `script-src 'unsafe-eval'`** — Next.js dev server + React
   Refresh use `eval()` for hot module replacement. The token is emitted
   **only** when `NODE_ENV !== 'production'`. Production bundles never carry
   it. Verified in `check-csp.mjs`.

3. ~~No report-uri / report-to endpoint yet.~~ **Resolved in R-02-B.**
   Reports land at `/api/csp-report` and emit structured log events. The
   remaining gate before `CSP_ENFORCE=1` is **7-day staging observation**
   with a signed-off review trail — not the absence of a collector.

## Adding a third-party origin

When you genuinely need a new origin (analytics, payment widget, image
CDN):

1. Add the origin to the matching directive in `lib/security/csp.ts`.
2. If the origin is fetched (not a script), add it to the allowlist in
   `scripts/check-csp.mjs` under `EXPECTED_ORIGINS` so drift is caught.
3. Add a row to the table below explaining **why** it was added and
   **who** approved it.
4. Verify locally with `CSP_ENFORCE=1 npm run dev` and page load —
   DevTools → Network → filter by CSP errors.

| Origin | Directive | Why |
|---|---|---|
| `https://*.supabase.co` | `img-src`, `connect-src`, `wss:` | Supabase storage + realtime |
| `https://*.supabase.in` | `img-src` | Supabase storage (legacy bucket URL) |
| `https://*.vercel-insights.com` | `connect-src` | Vercel Web Analytics beacons |

## CSP report collection endpoint

### Wire

```
Browser ──────── POST /api/csp-report ──────── app/api/csp-report/route.ts
                 (application/csp-report    ↓
                  | application/reports+json) normalizeCspReportBody()
                                             ↓
                                             logger.{error|warn|debug}('csp.violation.*', …)
```

### Event vocabulary

Every report collapses to exactly one structured log line. The event name is
enough to decide on-call severity without reading the body.

| Event | Level | When emitted |
|---|---|---|
| `csp.violation.enforced` | `error` | `disposition = enforce` — page actually broke for the user |
| `csp.violation.report_only` | `warn` | `disposition = report` — policy *would* have blocked |
| `csp.violation.low_signal` | `debug` | `blocked-uri` or `source-file` starts with `chrome-extension://`, `moz-extension://`, `safari-extension://`, `edge-extension://`, or is the bare `about` token |
| `csp.violation.rejected_oversize` | `debug` | Content-Length or actual body > 64 KB — returns 413 |
| `csp.violation.rejected_invalid_json` | `debug` | Body present but not JSON-parseable — returns 204 |
| `csp.violation.empty_or_unrecognized` | `debug` | Parsed but didn't match either shape — returns 204 |
| `csp.violation.batch_clipped` | `debug` | Reporting API array held more than 50 records — we logged the first 50 |

### Structured context fields

```
directive       effective-directive / effectiveDirective
blocked_uri     where the blocked resource was trying to load from
document_uri    the page that triggered the violation
source_file     the offending script URL (may be null)
line, column    offset into source_file
disposition     'enforce' | 'report' | null
status_code     HTTP status of the blocked request (may be null)
referrer        HTTP referrer, clipped at 4096 chars
payload_shape   'legacy' | 'reporting-api' | 'unknown' — useful for triage
user_agent      request header, kept as-is
```

### Privacy & safety

- **Same origin.** The endpoint lives under our own domain, so browsers post
  with existing cookies and no CORS handshake. We do not forward reports to
  any third party.
- **No echo.** Responses are always empty (204/413). The caller never sees
  the parsed report or any server state.
- **No PII by design.** CSP bodies contain URLs, directives, and source
  offsets — not request bodies, form data, or tokens. The normalizer clips
  strings at 2 048 chars (4 096 for URIs) belt-and-braces against pathological
  crafted reports.
- **Unauthenticated on purpose.** Browsers send reports *before* any session
  exists during first page load. Requiring auth would drop precisely the
  reports we need to see. The proxy matcher excludes `/api/csp-report` so the
  auth redirect does not swallow them.
- **Bot/crawler hygiene.** Malformed bodies never reach `warn` — they drop to
  `debug`, so dashboards stay low-noise. Real enforcement blocks light up at
  `error`.

## Rollout playbook (staging → production)

### Step 0. Preconditions

- Structured logs are being shipped somewhere queryable (Vercel Logs, Grafana
  Loki, or equivalent). Staging dashboards are wired up.
- `check:portability` is green on the current branch (`npm run check:portability`).

### Step 1. Deploy to staging with `CSP_ENFORCE` unset

Default is report-only. This is the safe mode — the browser observes but
never blocks. Nothing visible changes for users.

### Step 2. Exercise the user flows

The following flows must be exercised at least once so the policy sees every
page type:

- Auth: `/login`, `/signup`, `/forgot-password`, `/reset-password`, OAuth callback
- Dashboard: `/dashboard`
- Visits (projektanlage): create a visit, open detail, edit, delete
- Stopwatch: record cycle times
- Shift output: record shift data
- Workshop: file and close an improvement action
- QAF: upload + compare QAF documents
- Export: trigger the Excel export
- Account: edit profile
- Management: all analytics dashboards
- Owner portal: list tenants, open a tenant, open the provisioning detail
- Demo flows: any public demo or marketing preview route

Tester gathers a session id per flow so reports can be correlated later.

### Step 3. 7-day observation window

Daily review of the log stream. For each `csp.violation.*` entry:

- `csp.violation.enforced` or `.report_only` with our own origin as
  `blocked-uri` / `source-file` → **fix in code** (usually an inline script,
  or a forgotten `<Script>` without a nonce).
- External origin we genuinely need → **add the origin** to
  `lib/security/csp.ts` and the `EXPECTED_ORIGINS` table in
  `scripts/check-csp.mjs`, plus a row to the third-party origin table above.
- Extension / `about:blank` noise → should already be `.low_signal`; if not,
  extend `isLowSignalReport()` carefully (keep the list short).

Every mitigation gets a short note on the R-02 ticket: which report, which
commit. This is the review trail that unlocks enforcement.

### Step 4. Readiness gate

Before flipping `CSP_ENFORCE=1`:

- [ ] 7 consecutive days of staging traffic logged
- [ ] Zero unexplained `csp.violation.enforced` or `csp.violation.report_only`
      entries against our own origin in the last 48 hours
- [ ] All external origins are either allowlisted with justification or
      deliberately kept out (and their violations understood)
- [ ] `npm run check:portability` green on main
- [ ] Review trail entry per mitigation filed on the R-02 ticket
- [ ] Two reviewers sign off in `governance/scorecard/risk-register.md`

### Step 5. Flip in staging first

Set `CSP_ENFORCE=1` as a staging env var. Smoke-test every flow in Step 2.
Browser DevTools Console should show **no** CSP blocks. Log stream should
show `csp.violation.enforced` only for genuinely bad requests — if the count
per hour is materially higher than report-only was, **roll back the env var**
and investigate before touching production.

### Step 6. Production flip

After 48 hours clean in staging: set `CSP_ENFORCE=1` in production. Keep the
log dashboard open for the first 4 hours. Rollback path: unset the env var;
the policy reverts to report-only within the next deploy (or instantly on
platforms that re-evaluate env per request).

### Step 7. Governance close-out

- Move R-02 / R-02-B rows to **Resolved** in
  `governance/scorecard/risk-register.md`.
- Turn the CSP rail **green** in
  `governance/scorecard/architecture-scorecard.md`.
- Update `docs/foundation/follow-ups.md` to delete the CSP entry (or move it
  to the "Done" section if one exists).

## How to break CSP (so you can avoid it)

- **Inline `<script>` tag in JSX** without `nonce={nonce}`. Forbidden.
  If you need an inline script, read the nonce from `headers()` and pass
  it to `<Script nonce={nonce}>`.
- **`dangerouslySetInnerHTML` that contains `<script>` or `<style>`**.
  Forbidden. If unavoidable, nonce the tag and put it behind an ADR.
- **`eval()` or `new Function()`** in production code. Forbidden by CSP.
  The repo currently has zero occurrences; keep it that way.
- **Third-party script tags** (Segment, GTM, Hotjar, etc.) without
  nonces. Add the origin to `script-src` is **not** enough —
  `'strict-dynamic'` ignores host allowlists. Either nonce the tag on
  the server or load the vendor as a module imported by your own code.
