Files
flights_web/docs/superpowers/plans/2026-04-14-phase-1h-security.md
T

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)
  • 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:

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:

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:

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; 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