feat(web-next): wire security headers middleware and nonce threading

- Add apps/web-next/middleware.ts calling withSecurityHeaders() from
  core-shared/security/next; exports matcher config excluding static assets
- Update layout.tsx to call getNonce() and render <meta name="csp-nonce">
  so client-side JS can read the per-request nonce
- Update instrumentation-client.ts to read nonce from csp-nonce meta tag
  and pass it to initSentryClient for feedbackIntegration CSP compliance
- Add nonce option to initSentryClient (InitClientOpts.nonce) and thread
  styleNonce + scriptNonce into feedbackIntegration when provided
- Add middleware test asserting all six headers, prod/dev CSP shape, and
  x-nonce presence; add feedbackIntegration nonce tests to core-shared

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 10:10:12 +00:00
parent de458a6d1e
commit b681e906ea
7 changed files with 170 additions and 11 deletions

View File

@@ -3,8 +3,17 @@
import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client";
function getNonce(): string {
if (typeof document === "undefined") return "";
return (
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
""
);
}
initSentryClient({
dsn: process.env["NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN"],
app: "web-next",
release: process.env["NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA"],
nonce: getNonce(),
});

View File

@@ -0,0 +1,10 @@
import { withSecurityHeaders } from "@repo/core-shared/security/next";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
return withSecurityHeaders(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const responseMock = vi.hoisted(() => {
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),
},
};
}
return { makeResponseMock };
});
vi.mock("next/server", () => ({
NextResponse: {
next: vi.fn(),
},
}));
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { middleware } from "../../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;
}
describe("web-next middleware", () => {
let mock: ReturnType<typeof responseMock.makeResponseMock>;
beforeEach(() => {
mock = responseMock.makeResponseMock();
vi.mocked(NextResponse.next).mockReturnValue(
mock as unknown as ReturnType<typeof NextResponse.next>,
);
});
it("sets all six security headers on the response", () => {
middleware(makeRequest());
for (const header of ALL_SIX_HEADERS) {
expect(mock._store.has(header)).toBe(true);
}
});
it("sets x-nonce header on the response", () => {
middleware(makeRequest());
const nonce = mock._store.get("x-nonce");
expect(nonce).toBeDefined();
expect(typeof nonce).toBe("string");
expect((nonce as string).length).toBeGreaterThan(0);
});
it("CSP contains nonce in production mode", () => {
vi.stubEnv("NODE_ENV", "production");
middleware(makeRequest());
const nonce = mock._store.get("x-nonce");
const csp = mock._store.get("Content-Security-Policy");
expect(csp).toContain(`'nonce-${nonce}'`);
});
it("CSP is permissive (unsafe-inline) in development mode", () => {
vi.stubEnv("NODE_ENV", "development");
middleware(makeRequest());
const csp = mock._store.get("Content-Security-Policy");
expect(csp).toContain("'unsafe-inline'");
});
it("x-nonce is forwarded in request headers passed to NextResponse.next", () => {
middleware(makeRequest());
const call = vi.mocked(NextResponse.next).mock.calls[0] as [
{ request?: { headers?: Headers } } | undefined,
];
expect(call[0]?.request?.headers?.get("x-nonce")).toBeTruthy();
});
});

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { getNonce } from "@repo/core-shared/security/next";
import { Providers } from "./providers";
export const metadata: Metadata = {
@@ -6,13 +7,19 @@ export const metadata: Metadata = {
description: "Clean Architecture Monorepo Template",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const nonce = await getNonce();
return (
<html lang="en">
<head>
{/* nonce exposed to client so instrumentation-client.ts can read it */}
<meta name="csp-nonce" content={nonce} />
</head>
<body>
<Providers>{children}</Providers>
</body>