next.js icon indicating copy to clipboard operation
next.js copied to clipboard

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

Open BrianHHough opened this issue 1 year ago • 2 comments

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:

  • Codesandbox env: 'Hydration failed because the initial UI does not match what was rendered on the server.'
  • Local on my Mac: app-index.js:35 Warning: Prop nonce did not match. Server: "" Client: "ODMzNThhNzItYzgwYy00ZTk3LWJmYjItMDZiMDlhNGE1Y2Vh"
  • No I do not want to use suppressHydrationWarning in layout.tsx as that isn't a good security move.

🔗 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"
  }
}

  1. 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

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:

  • Right now, if I comment out <ThemeScript/> from the layout.tsx's <head> object, then the error goes away
  • This tells me that the nonce is not being passed correctly from my middleware.tsx file to layout.tsx into the <ThemeScript/> even though I should be able to get this via the method of const nonce = headers().get("x-nonce") ?? undefined; as I am doing in my ThemeScript() function.
  • Yes, I have read the docs on Vercel about CSP and nonces ((linked here)[https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy]), but this does not address the issue where I need to add a nonce to a

Expected Behavior:

  • I would expect the server and client to have the same nonce based on how the script is run so that the

Here is a screenshot of what I see: Screenshot 2024-03-27 at 12 14 33 AM

Provide environment information

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 22.3.0: Mon Jan 30 20:38:37 PST 2023; root:xnu-8792.81.3~2/RELEASE_ARM64_T6000
Binaries:
  Node: 18.17.1
  npm: 9.6.7
  Yarn: N/A
  pnpm: N/A
Relevant Packages:
  next: 14.1.4
  eslint-config-next: 14.1.4
  react: 18.2.0
  react-dom: 18.2.0
  typescript: 5.4.3
Next.js Config:
  output: N/A

Which area(s) are affected? (Select all that apply)

App Router, Dynamic imports (next/dynamic), Middleware / Edge (API routes, runtime), TypeScript (plugin, built-in types)

Which stage(s) are affected? (Select all that apply)

next dev (local)

Additional context

I am running this locally and also in a codesandbox, and the issues remain similar.

BrianHHough avatar Mar 27 '24 04:03 BrianHHough

Did you find any solution for this?

Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!

Yuniac avatar Oct 25 '24 11:10 Yuniac

I ran into this issue (albeit using nonce on a <form> with server actions) and did some digging. From what I saw, this is not a nextjs issue, but is more likely a react related one. Browsers implement something called "nonce hiding" which removes a nonce property and makes it return an empty string and instead only makes it available via the getAttribute('nonce') function as an XSS mitigation.

There's a good description here and a closed bug report on react about it.

As for solutions, I could be wrong, but I'm not sure there are any yet aside from the suppressHydrationWarning or just using an approach that doesn't add nonce properties to anything except <script> and <link>

richardasymmetric avatar Oct 25 '24 23:10 richardasymmetric

I run into the same problem, at least, this should then be mentioned in the docs

Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!

axten avatar Jan 21 '25 12:01 axten