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:
2026-05-20 09:46:07 +00:00
parent 6575a4857e
commit a736ed621d
8 changed files with 192 additions and 27 deletions

View File

@@ -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:*",

View 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("");
});
});

View 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") ?? "";
}

View File

@@ -0,0 +1,2 @@
export { withSecurityHeaders } from "./middleware";
export { getNonce } from "./get-nonce";

View 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);
});
});

View 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;
}