Implement CSP middleware with per-request nonce and React context
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { cspMiddleware, CspNonceContext } from "./csp.js";
|
||||||
|
|
||||||
|
function createMockRes() {
|
||||||
|
const headers = new Map<string, string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>("");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>,
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user