From a736ed621d541a32e2108ced0251efc45d95e6e9 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 20 May 2026 09:46:07 +0000 Subject: [PATCH] feat(core-shared): add Next.js security header middleware adapter Implements security/next subpath with withSecurityHeaders() middleware and getNonce() Server Component helper. Middleware generates a per-request nonce, calls buildSecurityHeaders, sets all six headers + x-nonce on the response, and forwards the nonce via request headers for Server Component access. Adds next as optional peer + dev dependency. Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 44 ++++----- packages/core-shared/package.json | 10 +- .../src/security/next/get-nonce.test.ts | 26 +++++ .../src/security/next/get-nonce.ts | 6 ++ .../core-shared/src/security/next/index.ts | 2 + .../src/security/next/middleware.test.ts | 98 +++++++++++++++++++ .../src/security/next/middleware.ts | 24 +++++ pnpm-lock.yaml | 9 +- 8 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 packages/core-shared/src/security/next/get-nonce.test.ts create mode 100644 packages/core-shared/src/security/next/get-nonce.ts create mode 100644 packages/core-shared/src/security/next/index.ts create mode 100644 packages/core-shared/src/security/next/middleware.test.ts create mode 100644 packages/core-shared/src/security/next/middleware.ts diff --git a/coverage/summary.json b/coverage/summary.json index 4960f61..31ebe37 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-20T09:32:45.528Z", - "commit": "650a97b", + "generatedAt": "2026-05-20T09:45:48.959Z", + "commit": "6575a48", "repo": { - "statements": 97.42, - "branches": 92.47, - "functions": 97.25, - "lines": 97.42, + "statements": 97.43, + "branches": 92.51, + "functions": 97.27, + "lines": 97.43, "counts": { - "lf": 6040, - "lh": 5884, - "brf": 1209, - "brh": 1118, - "fnf": 364, - "fnh": 354 + "lf": 6063, + "lh": 5907, + "brf": 1215, + "brh": 1124, + "fnf": 366, + "fnh": 356 } }, "byPackage": { @@ -101,17 +101,17 @@ } }, "@repo/core-shared": { - "statements": 98.34, - "branches": 96.33, - "functions": 93.28, - "lines": 98.34, + "statements": 98.37, + "branches": 96.39, + "functions": 93.39, + "lines": 98.37, "counts": { - "lf": 1265, - "lh": 1244, - "brf": 354, - "brh": 341, - "fnf": 119, - "fnh": 111 + "lf": 1288, + "lh": 1267, + "brf": 360, + "brh": 347, + "fnf": 121, + "fnh": 113 } }, "@repo/core-ui": { diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index ed78cc3..0927487 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -22,7 +22,8 @@ "./instrumentation/otel/init-server-node": "./src/instrumentation/otel/init-server-node.ts", "./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts", "./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts", - "./security": "./src/security/index.ts" + "./security": "./src/security/index.ts", + "./security/next": "./src/security/next/index.ts" }, "scripts": { "build": "tsc --noEmit", @@ -52,7 +53,8 @@ }, "peerDependencies": { "@sentry/node": "^10.51.0", - "@sentry/react": "^10.51.0" + "@sentry/react": "^10.51.0", + "next": ">=15.0.0" }, "peerDependenciesMeta": { "@sentry/node": { @@ -60,9 +62,13 @@ }, "@sentry/react": { "optional": true + }, + "next": { + "optional": true } }, "devDependencies": { + "next": "^15.3.0", "@opentelemetry/context-async-hooks": "^1.28.0", "@repo/core-eslint": "workspace:*", "@repo/core-testing": "workspace:*", diff --git a/packages/core-shared/src/security/next/get-nonce.test.ts b/packages/core-shared/src/security/next/get-nonce.test.ts new file mode 100644 index 0000000..41d1d97 --- /dev/null +++ b/packages/core-shared/src/security/next/get-nonce.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +import { headers } from "next/headers"; +import { getNonce } from "@/security/next/get-nonce"; + +describe("getNonce", () => { + it("reads x-nonce from the request headers provided by next/headers", async () => { + vi.mocked(headers).mockResolvedValue({ + get: (k: string) => (k === "x-nonce" ? "test-nonce-value" : null), + } as unknown as Awaited>); + + expect(await getNonce()).toBe("test-nonce-value"); + }); + + it("returns empty string when x-nonce header is absent", async () => { + vi.mocked(headers).mockResolvedValue({ + get: () => null, + } as unknown as Awaited>); + + expect(await getNonce()).toBe(""); + }); +}); diff --git a/packages/core-shared/src/security/next/get-nonce.ts b/packages/core-shared/src/security/next/get-nonce.ts new file mode 100644 index 0000000..60016d9 --- /dev/null +++ b/packages/core-shared/src/security/next/get-nonce.ts @@ -0,0 +1,6 @@ +import { headers } from "next/headers"; + +export async function getNonce(): Promise { + const headersList = await headers(); + return headersList.get("x-nonce") ?? ""; +} diff --git a/packages/core-shared/src/security/next/index.ts b/packages/core-shared/src/security/next/index.ts new file mode 100644 index 0000000..a2c2f8d --- /dev/null +++ b/packages/core-shared/src/security/next/index.ts @@ -0,0 +1,2 @@ +export { withSecurityHeaders } from "./middleware"; +export { getNonce } from "./get-nonce"; diff --git a/packages/core-shared/src/security/next/middleware.test.ts b/packages/core-shared/src/security/next/middleware.test.ts new file mode 100644 index 0000000..2d0a633 --- /dev/null +++ b/packages/core-shared/src/security/next/middleware.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ + NextResponse: { + next: vi.fn(), + }, +})); + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { withSecurityHeaders } from "@/security/next/middleware"; + +const ALL_SIX_HEADERS = [ + "Strict-Transport-Security", + "X-Frame-Options", + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + "Content-Security-Policy", +] as const; + +function makeRequest(): NextRequest { + return { headers: new Headers() } as unknown as NextRequest; +} + +function makeResponseMock() { + const store = new Map(); + return { + _store: store, + headers: { + set: vi.fn((k: string, v: string) => store.set(k, v)), + get: vi.fn((k: string) => store.get(k) ?? null), + }, + }; +} + +describe("withSecurityHeaders", () => { + let mock: ReturnType; + + beforeEach(() => { + mock = makeResponseMock(); + vi.mocked(NextResponse.next).mockReturnValue( + mock as unknown as ReturnType, + ); + }); + + it("sets all six security headers on the response", () => { + withSecurityHeaders(makeRequest()); + + for (const header of ALL_SIX_HEADERS) { + expect(mock._store.has(header)).toBe(true); + } + }); + + it("sets x-nonce on the response", () => { + withSecurityHeaders(makeRequest()); + + const nonce = mock._store.get("x-nonce"); + expect(nonce).toBeDefined(); + expect(typeof nonce).toBe("string"); + expect((nonce as string).length).toBeGreaterThan(0); + }); + + it("nonce in x-nonce matches nonce threaded into CSP in production mode", () => { + vi.stubEnv("NODE_ENV", "production"); + + withSecurityHeaders(makeRequest()); + + const nonce = mock._store.get("x-nonce"); + const csp = mock._store.get("Content-Security-Policy"); + expect(csp).toContain(`'nonce-${nonce}'`); + }); + + it("uses dev-mode CSP when NODE_ENV is not production", () => { + vi.stubEnv("NODE_ENV", "test"); + + withSecurityHeaders(makeRequest()); + + expect(mock._store.get("Content-Security-Policy")).toContain( + "'unsafe-inline'", + ); + }); + + it("forwards nonce via x-nonce in the request headers passed to NextResponse.next", () => { + withSecurityHeaders(makeRequest()); + + const call = vi.mocked(NextResponse.next).mock.calls[0] as [ + { request?: { headers?: Headers } } | undefined, + ]; + expect(call[0]?.request?.headers?.get("x-nonce")).toBeTruthy(); + }); + + it("returns the NextResponse from NextResponse.next", () => { + const result = withSecurityHeaders(makeRequest()); + + expect(result).toBe(mock); + }); +}); diff --git a/packages/core-shared/src/security/next/middleware.ts b/packages/core-shared/src/security/next/middleware.ts new file mode 100644 index 0000000..dc4dde4 --- /dev/null +++ b/packages/core-shared/src/security/next/middleware.ts @@ -0,0 +1,24 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { generateNonce } from "../nonce"; +import { buildSecurityHeaders } from "../build-security-headers"; + +export function withSecurityHeaders(request: NextRequest): NextResponse { + const nonce = generateNonce(); + const mode = process.env.NODE_ENV === "production" ? "prod" : "dev"; + const secHeaders = buildSecurityHeaders({ mode, nonce }); + + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-nonce", nonce); + + const response = NextResponse.next({ + request: { headers: requestHeaders }, + }); + + for (const [name, value] of Object.entries(secHeaders)) { + response.headers.set(name, value); + } + response.headers.set("x-nonce", nonce); + + return response; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41dc8a2..08a4f15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -750,7 +750,7 @@ importers: version: 1.40.0 "@sentry/nextjs": specifier: ^10.51.0 - version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@16.2.2(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2) + version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2) "@sentry/opentelemetry": specifier: ^10.51.0 version: 10.52.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) @@ -794,6 +794,9 @@ importers: inversify: specifier: ^6.2.0 version: 6.2.2(reflect-metadata@0.2.2) + next: + specifier: ^15.3.0 + version: 15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -16997,7 +17000,7 @@ snapshots: "@sentry/core@10.52.0": {} - "@sentry/nextjs@10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@16.2.2(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)": + "@sentry/nextjs@10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)": dependencies: "@opentelemetry/api": 1.9.1 "@opentelemetry/semantic-conventions": 1.40.0 @@ -17010,7 +17013,7 @@ snapshots: "@sentry/react": 10.51.0(react@19.2.4) "@sentry/vercel-edge": 10.51.0 "@sentry/webpack-plugin": 5.2.1(webpack@5.106.2) - next: 16.2.2(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) + next: 15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) rollup: 4.60.1 stacktrace-parser: 0.1.11 transitivePeerDependencies: