Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { cookies, headers } from 'next/headers';
import { Geist, Geist_Mono } from 'next/font/google';
import Script from 'next/script';
import './globals.css';
Expand Down Expand Up @@ -49,9 +49,10 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const cookieStore = await cookies();
const [cookieStore, headersList] = await Promise.all([cookies(), headers()]);
const themeCookie = cookieStore.get('theme');
const defaultTheme = themeCookie ? themeCookie.value : 'system';
const cspNonce = headersList.get('x-csp-nonce') ?? '';

// Read persisted locale to server-render the correct lang/dir on <html> —
// avoids a hydration flash for RTL users.
Expand Down Expand Up @@ -80,7 +81,7 @@ export default async function RootLayout({
return (
<html lang={locale} dir={dir} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script nonce={cspNonce} dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white text-gray-900 transition-colors duration-200 dark:bg-gray-950 dark:text-gray-50 flex flex-col min-h-screen`}
Expand All @@ -96,10 +97,11 @@ export default async function RootLayout({
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_ANALYTICS_ID}`}
strategy="lazyOnload"
nonce={cspNonce}
/>
)}
{process.env.NEXT_PUBLIC_ANALYTICS_ID && (
<Script id="analytics-init" strategy="lazyOnload">
<Script id="analytics-init" strategy="lazyOnload" nonce={cspNonce}>
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
Expand Down
5 changes: 4 additions & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export function middleware(request: NextRequest) {
const traceId = crypto.randomUUID();
request.headers.set('x-trace-id', traceId);

const cspNonce = crypto.randomUUID();
request.headers.set('x-csp-nonce', cspNonce);

// Handle redirects first (early in the chain)
const redirectResponse = handleRedirects(request);
if (redirectResponse) {
Expand All @@ -33,7 +36,7 @@ export function middleware(request: NextRequest) {
const withHeaders = (response: NextResponse) => {
response.headers.set('x-trace-id', traceId);
const withSecurity = applySecurityHeaders(response, request);
return applyCspHeaders(withSecurity, request);
return applyCspHeaders(withSecurity, request, cspNonce);
};

const permissionResponse = checkRoutePermission(request, userRole);
Expand Down
14 changes: 5 additions & 9 deletions src/middleware/csp.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { type NextResponse, type NextRequest } from 'next/server';

const NONCE_BYTES = 16;

export function generateNonce(): string {
const bytes = new Uint8Array(NONCE_BYTES);
crypto.getRandomValues(bytes);
return Buffer.from(bytes).toString('base64');
return crypto.randomUUID();
}

export interface CspOptions {
Expand Down Expand Up @@ -43,12 +39,12 @@ export function buildCspHeader(options: CspOptions): string {
.join('; ');
}

export function applyCspHeaders(response: NextResponse, request: NextRequest): NextResponse {
const nonce = generateNonce();
const csp = buildCspHeader({ nonce, strict: true });
export function applyCspHeaders(response: NextResponse, _request: NextRequest, nonce?: string): NextResponse {
const cspNonce = nonce ?? generateNonce();
const csp = buildCspHeader({ nonce: cspNonce, strict: true });

response.headers.set('Content-Security-Policy', csp);
response.headers.set('X-Nonce', nonce);
response.headers.set('X-Nonce', cspNonce);

return response;
}
Loading