# Phase 1H — Security Hardening

**Parent:** Phase 1 Foundation Master Plan
**Branch:** `plan/react-rewrite`

## Overview

Implements security hardening contracts: CSP nonce middleware, SSR stream nonce injection, standard security headers, and safe storage abstraction with Zod schema validation.

## Constraints

- Do NOT touch `ClientApp/`, ASP.NET, `wwwroot/`
- Do NOT modify `modern.config.ts` (1I wires middleware registrations)
- `zod` already installed (from 1A-1)
- Middleware exported as factory functions, not auto-registered

## Tasks

### Task 1: `src/shared/storage.ts` + tests (TDD)

**Files:**
- `src/shared/storage.ts`
- `src/shared/storage.test.ts`

**Contract:**
```ts
import type { ZodSchema } from "zod";

export const storage: {
  get<T>(key: string, schema: ZodSchema<T>): T | null;
  set<T>(key: string, value: T, schema: ZodSchema<T>): void;
  delete(key: string): void;
  clear(): void;
};
```

**Details:**
- All keys namespaced with `afl_` prefix
- `get` returns `null` when key missing, JSON parse fails, or Zod validation fails (never throws)
- `set` validates against schema before writing (throws on validation failure)
- `delete` removes the namespaced key
- `clear` removes only `afl_`-prefixed keys (not all storage)

**Tests:**
- get/set round-trip with valid schema
- get returns null for missing key
- get returns null when stored value fails schema validation
- set throws when value doesn't match schema
- delete removes the key
- clear removes only namespaced keys
- keys are stored with `afl_` prefix

### Task 2: `src/server/middleware/csp.ts` + tests (TDD)

**Files:**
- `src/server/middleware/csp.ts`
- `src/server/middleware/csp.test.ts`

**Contract:**
```ts
import { createContext } from "react";

export interface CspMiddlewareOptions {
  reportOnly?: boolean;
}

export function cspMiddleware(options?: CspMiddlewareOptions): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void;

export const CspNonceContext: React.Context<string>; // default ""
```

**Details:**
- Generates per-request nonce using `crypto.randomUUID()`
- Sets `Content-Security-Policy` header with `script-src 'nonce-{nonce}'`
- When `reportOnly: true`, uses `Content-Security-Policy-Report-Only` header
- Exposes nonce via `CspNonceContext` (default `""` on client)
- Nonce attached to request object for downstream middleware access

**Tests:**
- Generates unique nonce per call
- Sets CSP header with nonce
- reportOnly option uses report-only header
- Each invocation produces a different nonce
- CspNonceContext has default value of ""

### Task 3: `src/server/middleware/nonce-stream-transform.ts` + tests (TDD)

**Files:**
- `src/server/middleware/nonce-stream-transform.ts`
- `src/server/middleware/nonce-stream-transform.test.ts`

**Contract:**
```ts
export function wrapSsrStreamWithNonce(
  stream: NodeJS.ReadableStream,
  nonce: string,
): NodeJS.ReadableStream;
```

**Details:**
- Processes SSR HTML stream to inject `nonce="..."` on `<script>` tags without a nonce
- Does NOT double-inject on `<script nonce="...">` tags
- Handles `<script>`, `<script src="...">`, `<script type="module">` etc.
- Must handle chunks that split across tag boundaries

**Tests:**
- Injects nonce on bare `<script>` tags
- Injects nonce on `<script src="...">` tags
- Does not double-inject on `<script nonce="existing">` tags
- Handles multiple script tags in one chunk
- Handles chunks split mid-tag

### Task 4: `src/server/middleware/security-headers.ts` (no TDD)

**Files:**
- `src/server/middleware/security-headers.ts`

**Contract:**
```ts
export function securityHeadersMiddleware(): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void;
```

**Headers set:**
- `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: SAMEORIGIN`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: geolocation=(), camera=(), microphone=()`
- `Cross-Origin-Opener-Policy: same-origin`
- `Cross-Origin-Resource-Policy: cross-origin`

## Exit Gate

- `pnpm typecheck` passes
- `pnpm lint` passes
- `pnpm test` passes (all new + existing tests)
- `storage.get` with mismatching schema returns `null`
- CSP middleware generates unique nonce per request
- Every `<script>` in nonce-stream-transform output carries the nonce
