From 10b97339bf96955d150b060cf5416311faf5acd4 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:48:02 +0300 Subject: [PATCH] Implement CSP middleware with per-request nonce and React context --- src/server/middleware/csp.test.ts | 82 +++++++++++++++++++++++++++++++ src/server/middleware/csp.ts | 49 ++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/server/middleware/csp.test.ts create mode 100644 src/server/middleware/csp.ts diff --git a/src/server/middleware/csp.test.ts b/src/server/middleware/csp.test.ts new file mode 100644 index 00000000..0eea1d59 --- /dev/null +++ b/src/server/middleware/csp.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { cspMiddleware, CspNonceContext } from "./csp.js"; + +function createMockRes() { + const headers = new Map(); + return { + setHeader: vi.fn((name: string, value: string) => { + headers.set(name, value); + }), + headers, + }; +} + +describe("cspMiddleware", () => { + it("sets Content-Security-Policy header with a nonce", () => { + const middleware = cspMiddleware(); + const req = {} as Record; + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.setHeader).toHaveBeenCalledWith( + "Content-Security-Policy", + expect.stringContaining("'nonce-"), + ); + expect(next).toHaveBeenCalled(); + }); + + it("generates unique nonce per call", () => { + const middleware = cspMiddleware(); + const nonces: string[] = []; + + for (let i = 0; i < 5; i++) { + const req = {} as Record; + const res = createMockRes(); + middleware(req, res, vi.fn()); + const csp = res.headers.get("Content-Security-Policy") ?? ""; + const match = csp.match(/nonce-([^']+)/); + if (match) nonces.push(match[1]!); + } + + const unique = new Set(nonces); + expect(unique.size).toBe(5); + }); + + it("uses Content-Security-Policy-Report-Only when reportOnly is true", () => { + const middleware = cspMiddleware({ reportOnly: true }); + const req = {} as Record; + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.setHeader).toHaveBeenCalledWith( + "Content-Security-Policy-Report-Only", + expect.stringContaining("'nonce-"), + ); + }); + + it("attaches nonce to req.cspNonce", () => { + const middleware = cspMiddleware(); + const req = {} as Record; + const res = createMockRes(); + + middleware(req, res, vi.fn()); + + expect(typeof req["cspNonce"]).toBe("string"); + expect((req["cspNonce"] as string).length).toBeGreaterThan(0); + }); +}); + +describe("CspNonceContext", () => { + it("has a default value of empty string", () => { + // Access the _currentValue (React internals) or check via default + // We just verify the context exists and is created properly + expect(CspNonceContext).toBeDefined(); + // The default value is "" — we test this by checking the context's _defaultValue + // In React, createContext stores default as _currentValue + expect((CspNonceContext as unknown as { _currentValue: string })._currentValue).toBe(""); + }); +}); diff --git a/src/server/middleware/csp.ts b/src/server/middleware/csp.ts new file mode 100644 index 00000000..6ee71042 --- /dev/null +++ b/src/server/middleware/csp.ts @@ -0,0 +1,49 @@ +import { createContext } from "react"; +import crypto from "node:crypto"; + +export interface CspMiddlewareOptions { + reportOnly?: boolean; +} + +/** + * React context exposing the per-request CSP nonce. + * Default is "" — client-side components read empty string (no-op). + */ +export const CspNonceContext = createContext(""); + +/** + * Factory returning express-style middleware that: + * 1. Generates a per-request nonce + * 2. Sets the CSP header (or Report-Only variant) + * 3. Attaches `req.cspNonce` for downstream middleware + */ +export function cspMiddleware(options?: CspMiddlewareOptions) { + const headerName = options?.reportOnly + ? "Content-Security-Policy-Report-Only" + : "Content-Security-Policy"; + + return ( + req: Record, + res: { setHeader(name: string, value: string): void }, + next: () => void, + ): void => { + const nonce = crypto.randomUUID(); + + const policy = [ + `default-src 'self'`, + `script-src 'self' 'nonce-${nonce}'`, + `style-src 'self' 'unsafe-inline'`, + `img-src 'self' data: https:`, + `font-src 'self'`, + `connect-src 'self' https:`, + `frame-ancestors 'self'`, + `base-uri 'self'`, + `form-action 'self'`, + ].join("; "); + + res.setHeader(headerName, policy); + req["cspNonce"] = nonce; + + next(); + }; +}