From e168163f50e1e0b6b147cd4cd09128df0c0efa27 Mon Sep 17 00:00:00 2001 From: Mugiwara Date: Tue, 2 Jun 2026 09:44:36 +0100 Subject: [PATCH] Added content security header --- __tests__/csp-middleware.test.ts | 85 ++++++++++++++++++++++++++++++++ middleware.ts | 34 ++++++++++++- next.config.js | 4 ++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 __tests__/csp-middleware.test.ts diff --git a/__tests__/csp-middleware.test.ts b/__tests__/csp-middleware.test.ts new file mode 100644 index 00000000..56a05160 --- /dev/null +++ b/__tests__/csp-middleware.test.ts @@ -0,0 +1,85 @@ +import { describe, it, assert } from "vitest"; +import { NextRequest } from "next/server"; +import { middleware } from "@/middleware"; +import { CSRF_COOKIE_NAME } from "@/lib/auth/csrf"; + +describe("Content Security Policy Middleware Tests", () => { + it("should inject Content-Security-Policy header into the response", () => { + const req = new NextRequest("http://localhost/dashboard", { + method: "GET", + }); + + const res = middleware(req); + assert.ok(res, "Middleware must return a response"); + + const csp = res.headers.get("content-security-policy"); + assert.ok(csp, "Response must contain Content-Security-Policy header"); + assert.strictEqual(csp.includes("default-src 'self'"), true, "CSP should contain default-src 'self'"); + assert.strictEqual( + csp.includes("connect-src 'self' https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org"), + true, + "CSP should contain authorized connect-src URLs" + ); + assert.strictEqual( + csp.includes("img-src 'self' data: https://images.unsplash.com https://i.pravatar.cc"), + true, + "CSP should contain authorized img-src URLs" + ); + }); + + it("should generate a random base64 nonce and replace the placeholder", () => { + const req = new NextRequest("http://localhost/dashboard", { + method: "GET", + }); + + const res = middleware(req); + const csp = res.headers.get("content-security-policy"); + assert.ok(csp); + + // It should not contain the literal placeholder {nonce} + assert.strictEqual(csp.includes("{nonce}"), false, "Should not contain the raw {nonce} placeholder"); + + // It should contain 'nonce-...' + const nonceMatch = csp.match(/'nonce-([^']+)'/); + assert.ok(nonceMatch, "CSP must contain a nonce matching 'nonce-...'"); + + const nonceValue = nonceMatch[1]; + assert.ok(nonceValue, "Nonce value must not be empty"); + assert.strictEqual(nonceValue.length > 10, true, "Nonce value should have sufficient length"); + }); + + it("should generate unique nonces for separate requests", () => { + const req1 = new NextRequest("http://localhost/dashboard", { method: "GET" }); + const req2 = new NextRequest("http://localhost/dashboard", { method: "GET" }); + + const res1 = middleware(req1); + const res2 = middleware(req2); + + const csp1 = res1.headers.get("content-security-policy"); + const csp2 = res2.headers.get("content-security-policy"); + + assert.ok(csp1); + assert.ok(csp2); + + const nonce1 = csp1.match(/'nonce-([^']+)'/)?.[1]; + const nonce2 = csp2.match(/'nonce-([^']+)'/)?.[1]; + + assert.ok(nonce1); + assert.ok(nonce2); + assert.notStrictEqual(nonce1, nonce2, "Nonces generated for separate requests must be unique"); + }); + + it("should set CSRF token cookie on GET requests to /login if missing", () => { + const req = new NextRequest("http://localhost/login", { + method: "GET", + }); + + const res = middleware(req); + assert.ok(res); + + // CSRF cookie should be set + const setCookie = res.headers.get("set-cookie"); + assert.ok(setCookie, "Response should set cookies"); + assert.strictEqual(setCookie.includes(CSRF_COOKIE_NAME), true, "Response should set CSRF cookie"); + }); +}); diff --git a/middleware.ts b/middleware.ts index 52464306..67c203a9 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,7 +3,27 @@ import type { NextRequest } from "next/server"; import { generateCsrfToken, setCsrfCookie, CSRF_COOKIE_NAME } from "@/lib/auth/csrf"; export function middleware(req: NextRequest) { - const res = NextResponse.next(); + // Generate a cryptographically secure random base64-encoded nonce + const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); + + // Set the nonce on the request headers so it can be read in server components (if needed) + const requestHeaders = new Headers(req.headers); + requestHeaders.set("x-nonce", nonce); + + // Set the updated CSP header value on the request + const cspHeader = `default-src 'self'; script-src 'self' 'nonce-${nonce}'; connect-src 'self' https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org; img-src 'self' data: https://images.unsplash.com https://i.pravatar.cc`; + requestHeaders.set("Content-Security-Policy", cspHeader); + + // Create response passing modified request headers + const res = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + + // Set the CSP header on the response + res.headers.set("Content-Security-Policy", cspHeader); + const path = req.nextUrl.pathname; // On GET requests to auth pages, set a csrf_token cookie if it doesn't exist yet @@ -18,5 +38,15 @@ export function middleware(req: NextRequest) { } export const config = { - matcher: ["/login", "/signup"], + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + "/((?!api|_next/static|_next/image|favicon.ico).*)", + ], }; + diff --git a/next.config.js b/next.config.js index bd3c4e3d..ff4cd33e 100644 --- a/next.config.js +++ b/next.config.js @@ -42,6 +42,10 @@ const securityHeaders = [ key: "Permissions-Policy", value: "camera=(), microphone=()", }, + { + key: "Content-Security-Policy", + value: "default-src 'self'; script-src 'self' 'nonce-{nonce}'; connect-src 'self' https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org; img-src 'self' data: https://images.unsplash.com https://i.pravatar.cc", + }, ]; /**