diff --git a/docs/superpowers/plans/2026-04-14-phase-1h-security.md b/docs/superpowers/plans/2026-04-14-phase-1h-security.md new file mode 100644 index 00000000..074caacb --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1h-security.md @@ -0,0 +1,139 @@ +# 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(key: string, schema: ZodSchema): T | null; + set(key: string, value: T, schema: ZodSchema): 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; // 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 `