plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
Showing only changes of commit 726db20f89 - Show all commits
@@ -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<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