4.3 KiB
4.3 KiB
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) zodalready 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.tssrc/shared/storage.test.ts
Contract:
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 getreturnsnullwhen key missing, JSON parse fails, or Zod validation fails (never throws)setvalidates against schema before writing (throws on validation failure)deleteremoves the namespaced keyclearremoves onlyafl_-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.tssrc/server/middleware/csp.test.ts
Contract:
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-Policyheader withscript-src 'nonce-{nonce}' - When
reportOnly: true, usesContent-Security-Policy-Report-Onlyheader - 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.tssrc/server/middleware/nonce-stream-transform.test.ts
Contract:
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:
export function securityHeadersMiddleware(): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void;
Headers set:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: geolocation=(), camera=(), microphone=()Cross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: cross-origin
Exit Gate
pnpm typecheckpassespnpm lintpassespnpm testpasses (all new + existing tests)storage.getwith mismatching schema returnsnull- CSP middleware generates unique nonce per request
- Every
<script>in nonce-stream-transform output carries the nonce