Add Phase 1H security hardening implementation plan
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user