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 <noreply@anthropic.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
26
packages/core-shared/src/security/next/get-nonce.test.ts
Normal file
26
packages/core-shared/src/security/next/get-nonce.test.ts
Normal file
@@ -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<ReturnType<typeof headers>>);
|
||||
|
||||
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<ReturnType<typeof headers>>);
|
||||
|
||||
expect(await getNonce()).toBe("");
|
||||
});
|
||||
});
|
||||
6
packages/core-shared/src/security/next/get-nonce.ts
Normal file
6
packages/core-shared/src/security/next/get-nonce.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function getNonce(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
return headersList.get("x-nonce") ?? "";
|
||||
}
|
||||
2
packages/core-shared/src/security/next/index.ts
Normal file
2
packages/core-shared/src/security/next/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { withSecurityHeaders } from "./middleware";
|
||||
export { getNonce } from "./get-nonce";
|
||||
98
packages/core-shared/src/security/next/middleware.test.ts
Normal file
98
packages/core-shared/src/security/next/middleware.test.ts
Normal file
@@ -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<string, string>();
|
||||
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<typeof makeResponseMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = makeResponseMock();
|
||||
vi.mocked(NextResponse.next).mockReturnValue(
|
||||
mock as unknown as ReturnType<typeof NextResponse.next>,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
24
packages/core-shared/src/security/next/middleware.ts
Normal file
24
packages/core-shared/src/security/next/middleware.ts
Normal file
@@ -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;
|
||||
}
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user