diff --git a/apps/web-next/instrumentation-client.ts b/apps/web-next/instrumentation-client.ts index 7e529e8..9cb29c3 100644 --- a/apps/web-next/instrumentation-client.ts +++ b/apps/web-next/instrumentation-client.ts @@ -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(), }); diff --git a/apps/web-next/middleware.ts b/apps/web-next/middleware.ts new file mode 100644 index 0000000..92d7f1c --- /dev/null +++ b/apps/web-next/middleware.ts @@ -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).*)"], +}; diff --git a/apps/web-next/src/__tests__/middleware.test.ts b/apps/web-next/src/__tests__/middleware.test.ts new file mode 100644 index 0000000..f1b4203 --- /dev/null +++ b/apps/web-next/src/__tests__/middleware.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const responseMock = vi.hoisted(() => { + 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), + }, + }; + } + 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; + + beforeEach(() => { + mock = responseMock.makeResponseMock(); + vi.mocked(NextResponse.next).mockReturnValue( + mock as unknown as ReturnType, + ); + }); + + 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(); + }); +}); diff --git a/apps/web-next/src/app/layout.tsx b/apps/web-next/src/app/layout.tsx index 623df37..c1a620b 100644 --- a/apps/web-next/src/app/layout.tsx +++ b/apps/web-next/src/app/layout.tsx @@ -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 ( + + {/* nonce exposed to client so instrumentation-client.ts can read it */} + + {children} diff --git a/coverage/summary.json b/coverage/summary.json index 8a0cba9..fc65fa0 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,16 +1,16 @@ { - "generatedAt": "2026-05-20T09:55:49.120Z", - "commit": "6903c59", + "generatedAt": "2026-05-20T10:09:50.939Z", + "commit": "de458a6", "repo": { "statements": 97.43, - "branches": 92.56, + "branches": 92.57, "functions": 97.28, "lines": 97.43, "counts": { "lf": 6079, "lh": 5923, - "brf": 1223, - "brh": 1132, + "brf": 1224, + "brh": 1133, "fnf": 368, "fnh": 358 } @@ -102,14 +102,14 @@ }, "@repo/core-shared": { "statements": 98.39, - "branches": 96.47, + "branches": 96.48, "functions": 93.5, "lines": 98.39, "counts": { "lf": 1304, "lh": 1283, - "brf": 368, - "brh": 355, + "brf": 369, + "brh": 356, "fnf": 123, "fnh": 115 } diff --git a/packages/core-shared/src/instrumentation/sentry/init-client.test.ts b/packages/core-shared/src/instrumentation/sentry/init-client.test.ts index e9f559c..889f403 100644 --- a/packages/core-shared/src/instrumentation/sentry/init-client.test.ts +++ b/packages/core-shared/src/instrumentation/sentry/init-client.test.ts @@ -1,17 +1,22 @@ // packages/core-shared/src/instrumentation/sentry/init-client.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; -const { replayIntegration } = vi.hoisted(() => { +const { replayIntegration, feedbackIntegration } = vi.hoisted(() => { const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts, })); - return { replayIntegration }; + const feedbackIntegration = vi.fn((opts: unknown) => ({ + name: "Feedback", + _opts: opts, + })); + return { replayIntegration, feedbackIntegration }; }); vi.mock("@sentry/nextjs", () => ({ init: vi.fn(), replayIntegration, + feedbackIntegration, })); import * as Sentry from "@sentry/nextjs"; @@ -65,4 +70,29 @@ describe("initSentryClient", () => { initSentryClient({ dsn: "", app: "web-next" }); expect(Sentry.init).not.toHaveBeenCalled(); }); + + it("attaches feedbackIntegration when Sentry.feedbackIntegration is available", () => { + initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); + expect(feedbackIntegration).toHaveBeenCalledTimes(1); + }); + + it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => { + initSentryClient({ + dsn: "https://x@y/1", + app: "web-next", + nonce: "abc123", + }); + const feedbackOpts = (feedbackIntegration as ReturnType).mock + .calls[0]![0] as Record; + expect(feedbackOpts["styleNonce"]).toBe("abc123"); + expect(feedbackOpts["scriptNonce"]).toBe("abc123"); + }); + + it("omits nonce props from feedbackIntegration when nonce not provided", () => { + initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); + const feedbackOpts = (feedbackIntegration as ReturnType).mock + .calls[0]![0] as Record; + expect(feedbackOpts["styleNonce"]).toBeUndefined(); + expect(feedbackOpts["scriptNonce"]).toBeUndefined(); + }); }); diff --git a/packages/core-shared/src/instrumentation/sentry/init-client.ts b/packages/core-shared/src/instrumentation/sentry/init-client.ts index 2df0374..13a7f3b 100644 --- a/packages/core-shared/src/instrumentation/sentry/init-client.ts +++ b/packages/core-shared/src/instrumentation/sentry/init-client.ts @@ -16,6 +16,7 @@ export type InitClientOpts = { dsn: string | undefined; app: "web-next" | "cms" | "web-tanstack"; release?: string; + nonce?: string; }; // Inline scrub helpers for browser-side Sentry (server uses OTel processors instead). @@ -70,6 +71,7 @@ function scrubUrl(url: string): string { export function initSentryClient(opts: InitClientOpts): void { if (!opts.dsn) return; + const { nonce } = opts; const isProd = process.env["NODE_ENV"] === "production"; const tracesSampleRate = @@ -127,6 +129,13 @@ export function initSentryClient(opts: InitClientOpts): void { maskAllInputs: true, blockAllMedia: true, }), + ...(Sentry.feedbackIntegration + ? [ + Sentry.feedbackIntegration({ + ...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}), + }), + ] + : []), ], initialScope: { tags: { app: opts.app } }, });