vercel / next.js

The React Framework
https://nextjs.org
MIT License
127.12k stars 27.01k forks source link

Unable to add nonce to script - difference between Server and Client using Next.js 14 App Router and Middleware (Content Security Policy) #63749

Open BrianHHough opened 7 months ago

BrianHHough commented 7 months ago

Link to the code that reproduces this issue

https://codesandbox.io/p/devbox/nextjs-approuter-middleware-content-security-policy-cs37j3

To Reproduce

I created a Codesandbox of the code and it is giving me a different error than what I see locally, but it's the same issue:

πŸ”— Codsandbox link: https://codesandbox.io/p/devbox/nextjs-approuter-middleware-content-security-policy-cs37j3

  1. Clone starter repo: https://github.com/vercel/next.js/tree/canary/examples/with-strict-csp
  2. Install these packages (package.json attached):
    
    {
    "name": "next-testing-content-security-policy",
    "version": "0.1.0",
    "private": true,
    "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
    },
    "dependencies": {
    "@emotion/cache": "^11.11.0",
    "@emotion/react": "^11.11.4",
    "@emotion/styled": "^11.11.0",
    "@mui/icons-material": "^5.15.14",
    "@mui/material": "^5.15.14",
    "@mui/material-nextjs": "^5.15.11",
    "next": "14.1.4",
    "react": "^18",
    "react-dom": "^18"
    },
    "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "typescript": "^5"
    }
    }
3. Create a theming script for light mode / dark mode (without white flash) - so it needs to pull from the requests in order to render out the right solution. This is my code in the steps to reproduce:

The functionality is a light mode and dark mode toggle and it works exactly the way that it should where it will check on the server what the preference is set for the user and then pass that into the app via next.js 14 app router middleware.

This also gets rid of the FOUC (i think that's what it's called) where there is a white flash on the dark option before rendering/recognizing that it was the selected version. This white half-second flash doesn't occur, which is ideal. That's what I mean when I say the feature is perfect except for this nonce did not match error.

Here is my code and let's debug together:

middleware.ts
```ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
    const nonce = Buffer.from(crypto.randomUUID()).toString("base64") || '';
    console.log('nonce in middlweare', `${nonce}.........`)
    const cspHeader = `
        default-src 'self';
        script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: ${
        process.env.NODE_ENV === "production" ? "" : `'unsafe-eval' http:`
    };
        style-src 'self' 'nonce-${nonce}';
        img-src 'self' blob: data: https://upload.wikimedia.org;
        font-src 'self';
        object-src 'none';
        base-uri 'self';
        form-action 'self';
        frame-ancestors 'none';
        block-all-mixed-content;
        upgrade-insecure-requests;
    `;
    {/* 

        default-src 'self';
        script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: http: 'unsafe-inline' ${
        process.env.NODE_ENV === "production" ? "" : `'unsafe-eval'`
    };
        style-src 'self' 'nonce-${nonce}';
        img-src 'self' blob: data: https://upload.wikimedia.org;
        font-src 'self';
        object-src 'none';
        base-uri 'self';
        form-action 'self';
        frame-ancestors 'none';
        block-all-mixed-content;
        upgrade-insecure-requests;

    */}

    // Replace newline characters and spaces
    const contentSecurityPolicyHeaderValue = cspHeader
        .replace(/\s{2,}/g, " ")
        .trim();

    const requestHeaders = new Headers(request.headers);
    requestHeaders.set("x-nonce", nonce);
    requestHeaders.set(
        "Content-Security-Policy",
        contentSecurityPolicyHeaderValue,
    );

    const response = NextResponse.next({
        request: {
        headers: requestHeaders,
        },
    });

    // Add CSP
    response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue);

    // Tell browser to only access via HTTPS
    response.headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');

    // Prevent browser from guessing tyupe of content if Content-Type header not set, prevent XSS exploits
    response.headers.set('X-Content-Type-Options', 'nosniff');

    // Control info sent when navigating to another 
    response.headers.set('Referrer-Policy', 'origin-when-cross-origin');

    // Set nonce cookie
    response.cookies.set('csp-nonce', nonce, { httpOnly: true, sameSite: 'strict' });

    return response;
}

export const config = {
    matcher: [
        /*
        * Match all request paths except for the ones starting with:
        * - api (API routes)
        * - _next/static (static files)
        * - _next/image (image optimization files)
        * - favicon.ico (favicon file)
        */
        {
        source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
        missing: [
            { type: "header", key: "next-router-prefetch" },
            { type: "header", key: "purpose", value: "prefetch" },
        ],
        },
    ],
};

app/page.tsx

import { headers } from "next/headers";
import Script from "next/script";
import Link from "next/link";
import SVGItem from "./components/SVGItem";
import TestClient from "./components/TestClient";
import TestImageComponent from "./components/TestImageComponent";
import Image from "./components/lib/Image";
import Head from "next/head";
import { Metadata } from "next";

import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { LinearProgress } from "@mui/material";

import dynamic from 'next/dynamic';
import LoadingThemeButton from '@/app/components/LoadingThemeButton';

const SetThemeButton = dynamic(() => import('@/app/components/SetThemeButton'), {
  ssr: false,
  loading: () => <LoadingThemeButton/>,
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app"
};

export default function Page() {
  const nonce = headers().get("x-nonce") ?? undefined;
  console.log('nonce in page', nonce)

  return (
    <main>
    <h1>hello</h1>
    {/* <LinearProgress color="success" /> */}
    <SetThemeButton />
    <h3>hello</h3>
    </main>
);
}

layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

// MUI
import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import {lightTheme, darkTheme} from '@/app/styles/theme';
import Script from "next/script";
import ThemeScript from "@/app/styles/ThemeToggle";
import { headers } from "next/headers";

// Font
const inter = Inter({ subsets: ["latin"] });

// export const metadata: Metadata = {
//   title: "Create Next App",
//   description: "Generated by create next app",
// };

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {

  // const nonce = headers().get("x-nonce");
  // console.log('nonce in layout', nonce)

  return (
    <html lang="en">
      <head>
        <ThemeScript />
      </head>
        <body className={inter.className}>
          <AppRouterCacheProvider>
            <ThemeProvider theme={darkTheme}>
              {children}
            </ThemeProvider>  
          </AppRouterCacheProvider>
        </body>
    </html>
  );
}

app/styles/ThemeToggle.tsx

import { cookies, headers } from "next/headers";
import Script from "next/script";

type Theme = 'light' | 'dark';
type ThemeScriptProps = {
  nonce: string; // nonce is a required string
};

declare global {
  interface Window {
    __theme: Theme;
    __onThemeChange: (theme: Theme) => void;
    __setPreferredTheme: (theme: Theme) => void;
  }
}

function code() {
  window.__onThemeChange = function () {};

  function setTheme(newTheme: Theme) {
    window.__theme = newTheme;
    preferredTheme = newTheme;
    document.documentElement.dataset.theme = newTheme;
    window.__onThemeChange(newTheme);
  }

  var preferredTheme;

  try {
    preferredTheme = localStorage.getItem('theme') as Theme;
  } catch (err) {}

  window.__setPreferredTheme = function (newTheme: Theme) {
    setTheme(newTheme);
    try {
      localStorage.setItem('theme', newTheme);
    } catch (err) {}
  };

  var darkQuery = window.matchMedia('(prefers-color-scheme: dark)');

  darkQuery.addEventListener('change', function (e) {
    window.__setPreferredTheme(e.matches ? 'dark' : 'light');
  });

  setTheme(preferredTheme || (darkQuery.matches ? 'dark' : 'light'));
}

export default function ThemeScript() {
  const nonce = headers().get("x-nonce") ?? undefined;
  console.log('nonce in theme', nonce)

  // const nonce = cookies().get("csp-nonce") ?? undefined;
  // console.log('nonce in theme', nonce?.value!)

  return (
    <script nonce={nonce} dangerouslySetInnerHTML={{ __html: `(${code})();` }} />

  // return (
    // <Script 
    //   src="public/theme-switcher.js"
    //   nonce={nonce}
    //   strategy="beforeInteractive"
    // />
  )

}

components/LoadingThemeButton.tsx

const LoadingThemeButton = () => {
    return <button>loading...</button>;
  };

  export default LoadingThemeButton;

app/components/SetTheme.tsx

'use client'
import React, { useState, useEffect } from 'react';

const SetTheme = (): JSX.Element => {
  const [theme, setTheme] = useState<string>((global.window as any)?.__theme || 'dark');

  const isDark = theme === 'dark';

  const toggleTheme = () => {
    (global.window as any)?.__setPreferredTheme(theme === 'light' ? 'dark' : 'light');
  };

  useEffect(() => {
    (global.window as any).__onThemeChange = setTheme;
  }, []);

  return <button onClick={toggleTheme}>{isDark ? 'Light' : 'Dark'}</button>;
};

export default SetTheme;

app/components/SetThemeButton.tsx

'use client';
import { useState, useEffect } from 'react';

const SetThemeButton = ({ nonce }: {nonce: string}) => {
  const [theme, setTheme] = useState(global.window?.__theme || 'light');
  console.log('nonce from SetThemeButton', nonce)

  const isDark = theme === 'dark';

  const toggleTheme = () => {
    global.window?.__setPreferredTheme(isDark ? 'light' : 'dark');
  };

  useEffect(() => {
    global.window.__onThemeChange = setTheme;
  }, []);

  return <button id="ThemeToggleButton" nonce={nonce} onClick={toggleTheme}>{isDark ? 'dark' : 'light'}</button>;
};

export default SetThemeButton;

Current vs. Expected behavior

Current Behavior: