Repository: vercel/next.js
Pull request: Add nonce handling from CSP in pages router

Our docs never called out that the nonce from a `content-security-policy` header would only automatically be set in app router. This adds handling to ensure it is also set for pages router. 

Fixes #67042
Closes NEXT-4104

Changed files:
filename: packages/next/src/pages/_document.tsx
patch: @@ -1200,10 +1200,10 @@ export default class Document<P = {}> extends React.Component<
   render() {
     return (
       <Html>
-        <Head />
+        <Head nonce={this.props.nonce} />
         <body>
           <Main />
-          <NextScript />
+          <NextScript nonce={this.props.nonce} />
         </body>
       </Html>
     )

filename: packages/next/src/server/render.tsx
patch: @@ -104,6 +104,7 @@ import { getCacheControlHeader } from './lib/cache-control'
 import { getErrorSource } from '../shared/lib/error-source'
 import type { DeepReadonly } from '../shared/lib/deep-readonly'
 import type { PagesDevOverlayType } from '../client/components/react-dev-overlay/pages/pages-dev-overlay'
+import { getScriptNonceFromHeader } from './app-render/get-script-nonce-from-header'
 
 let tryGetPreviewData: typeof import('./api-utils/node/try-get-preview-data').tryGetPreviewData
 let warn: typeof import('../build/output/log').warn
@@ -740,6 +741,13 @@ export async function renderToHTMLImpl(
       .map((script: any) => script.props)
   }
 
+  const csp =
+    req.headers['content-security-policy'] ||
+    req.headers['content-security-policy-report-only']
+
+  const nonce =
+    typeof csp === 'string' ? getScriptNonceFromHeader(csp) : undefined
+
   const AppContainer = ({ children }: { children: JSX.Element }) => (
     <AppRouterContext.Provider value={appRouter}>
       <SearchParamsContext.Provider value={adaptForSearchParams(router)}>
@@ -760,6 +768,7 @@ export async function renderToHTMLImpl(
                     },
                     scripts: initialScripts,
                     mountedInstances: new Set(),
+                    nonce,
                   }}
                 >
                   <LoadableContext.Provider
@@ -843,7 +852,7 @@ export async function renderToHTMLImpl(
       const { html, head: renderPageHead } = await docCtx.renderPage({
         enhanceApp,
       })
-      const styles = jsxStyleRegistry.styles({ nonce: options.nonce })
+      const styles = jsxStyleRegistry.styles({ nonce: options.nonce || nonce })
       jsxStyleRegistry.flush()
       return { html, head: renderPageHead, styles }
     },
@@ -1504,6 +1513,7 @@ export async function renderToHTMLImpl(
       isPreview: isPreview === true ? true : undefined,
       notFoundSrcPage: notFoundSrcPage && dev ? notFoundSrcPage : undefined,
     },
+    nonce,
     strictNextHead: renderOpts.strictNextHead,
     buildManifest: filteredBuildManifest,
     docComponentsRendered,

filename: packages/next/src/shared/lib/html-context.shared-runtime.ts
patch: @@ -8,6 +8,7 @@ import { createContext, useContext, type JSX } from 'react'
 
 export type HtmlProps = {
   __NEXT_DATA__: NEXT_DATA
+  nonce?: string
   strictNextHead: boolean
   dangerousAsPath: string
   docComponentsRendered: {

filename: test/production/app-dir/subresource-integrity/fixture/pages/pages.tsx
patch: @@ -0,0 +1,22 @@
+import React from 'react'
+import Script from 'next/script'
+
+export function getServerSideProps() {
+  return {
+    props: {
+      now: Date.now(),
+    },
+  }
+}
+
+export default function Page() {
+  return (
+    <>
+      <p>pages-nonce</p>
+      <Script
+        dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }}
+        strategy="afterInteractive"
+      />
+    </>
+  )
+}

filename: test/production/app-dir/subresource-integrity/subresource-integrity.test.ts
patch: @@ -7,226 +7,232 @@ import cheerio from 'cheerio'
 ;(process.env.IS_TURBOPACK_TEST ? describe.skip : describe)(
   'Subresource Integrity',
   () => {
-    describe.each(['node', 'edge'] as const)('with %s runtime', (runtime) => {
-      const { next } = nextTestSetup({
-        files: path.join(__dirname, 'fixture'),
-      })
-
-      function fetchWithPolicy(policy: string | null, reportOnly?: boolean) {
-        const cspKey = reportOnly
-          ? 'Content-Security-Policy-Report-Only'
-          : 'Content-Security-Policy'
-        return next.fetch(`/${runtime}`, {
-          headers: policy
-            ? {
-                [cspKey]: policy,
-              }
-            : {},
+    describe.each(['node', 'edge', 'pages'] as const)(
+      'with %s runtime',
+      (runtime) => {
+        const { next } = nextTestSetup({
+          files: path.join(__dirname, 'fixture'),
         })
-      }
 
-      async function renderWithPolicy(
-        policy: string | null,
-        reportOnly?: boolean
-      ) {
-        const res = await fetchWithPolicy(policy, reportOnly)
+        function fetchWithPolicy(policy: string | null, reportOnly?: boolean) {
+          const cspKey = reportOnly
+            ? 'Content-Security-Policy-Report-Only'
+            : 'Content-Security-Policy'
+          return next.fetch(`/${runtime}`, {
+            headers: policy
+              ? {
+                  [cspKey]: policy,
+                }
+              : {},
+          })
+        }
 
-        expect(res.ok).toBe(true)
+        async function renderWithPolicy(
+          policy: string | null,
+          reportOnly?: boolean
+        ) {
+          const res = await fetchWithPolicy(policy, reportOnly)
 
-        const html = await res.text()
+          expect(res.ok).toBe(true)
 
-        return cheerio.load(html)
-      }
+          const html = await res.text()
 
-      it('does not include nonce when not enabled', async () => {
-        const policies = [
-          `script-src 'nonce-'`, // invalid nonce
-          'style-src "nonce-cmFuZG9tCg=="', // no script or default src
-          '', // empty string
-        ]
+          return cheerio.load(html)
+        }
 
-        for (const policy of policies) {
-          const $ = await renderWithPolicy(policy)
+        it('does not include nonce when not enabled', async () => {
+          const policies = [
+            `script-src 'nonce-'`, // invalid nonce
+            'style-src "nonce-cmFuZG9tCg=="', // no script or default src
+            '', // empty string
+          ]
 
-          // Find all the script tags without src attributes and with nonce
-          // attributes.
-          const elements = $('script[nonce]:not([src])')
+          for (const policy of policies) {
+            const $ = await renderWithPolicy(policy)
 
-          // Expect there to be none.
-          expect(elements.length).toBe(0)
-        }
-      })
-
-      it('includes a nonce value with inline scripts when Content-Security-Policy header is defined', async () => {
-        // A random nonce value, base64 encoded.
-        const nonce = 'cmFuZG9tCg=='
-
-        // Validate all the cases where we could parse the nonce.
-        const policies = [
-          `script-src 'nonce-${nonce}'`, // base case
-          `   script-src   'nonce-${nonce}' `, // extra space added around sources and directive
-          `style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
-          `script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
-          `default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
-          `default-src 'nonce-${nonce}'`, // fallback case
-        ]
-
-        for (const policy of policies) {
-          const $ = await renderWithPolicy(policy)
-
-          // Find all the script tags without src attributes.
-          const elements = $('script:not([src])')
-
-          // Expect there to be at least 1 script tag without a src attribute.
-          expect(elements.length).toBeGreaterThan(0)
-
-          // Expect all inline scripts to have the nonce value.
-          elements.each((i, el) => {
-            expect(el.attribs['nonce']).toBe(nonce)
-          })
-        }
-      })
-
-      it('includes a nonce value with inline scripts when Content-Security-Policy-Report-Only header is defined', async () => {
-        // A random nonce value, base64 encoded.
-        const nonce = 'cmFuZG9tCg=='
-
-        // Validate all the cases where we could parse the nonce.
-        const policies = [
-          `script-src 'nonce-${nonce}'`, // base case
-          `   script-src   'nonce-${nonce}' `, // extra space added around sources and directive
-          `style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
-          `script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
-          `default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
-          `default-src 'nonce-${nonce}'`, // fallback case
-        ]
-
-        for (const policy of policies) {
-          const $ = await renderWithPolicy(policy, true)
-
-          // Find all the script tags without src attributes.
-          const elements = $('script:not([src])')
-
-          // Expect there to be at least 1 script tag without a src attribute.
-          expect(elements.length).toBeGreaterThan(0)
-
-          // Expect all inline scripts to have the nonce value.
-          elements.each((i, el) => {
-            expect(el.attribs['nonce']).toBe(nonce)
-          })
-        }
-      })
-
-      it('includes a nonce value with bootstrap scripts when Content-Security-Policy header is defined', async () => {
-        // A random nonce value, base64 encoded.
-        const nonce = 'cmFuZG9tCg=='
-
-        // Validate all the cases where we could parse the nonce.
-        const policies = [
-          `script-src 'nonce-${nonce}'`, // base case
-          `   script-src   'nonce-${nonce}' `, // extra space added around sources and directive
-          `style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
-          `script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
-          `default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
-          `default-src 'nonce-${nonce}'`, // fallback case
-        ]
-
-        for (const policy of policies) {
-          const $ = await renderWithPolicy(policy)
-
-          // Find all the script tags without src attributes.
-          const elements = $('script[src]')
-
-          // Expect there to be at least 2 script tag with a src attribute.
-          // The main chunk and the webpack runtime.
-          expect(elements.length).toBeGreaterThan(1)
-
-          // Expect all inline scripts to have the nonce value.
-          elements.each((i, el) => {
-            expect(el.attribs['nonce']).toBe(nonce)
-          })
-        }
-      })
-
-      it('includes an integrity attribute on scripts', async () => {
-        const $ = await next.render$(`/${runtime}`)
-        // Currently webpack chunks loaded via flight runtime do not get integrity
-        // hashes. This was previously unobservable in this test because these scripts
-        // are inserted by the webpack runtime and immediately removed from the document.
-        // However with the advent of preinitialization of chunks used during SSR there are
-        // some script tags for flight loaded chunks that will be part of the initial HTML
-        // but do not have integrity hashes. Flight does not currently support a way to
-        // provide integrity hashes for these chunks. When this is addressed in React upstream
-        // we can revisit this tests assertions and start to ensure it actually applies to
-        // all SSR'd scripts. For now we will look for known entrypoint scripts and assume
-        // everything else in the <head> is part of flight loaded chunks
-
-        // Collect all the scripts with integrity hashes so we can verify them.
-        const files: Map<string, string> = new Map()
-
-        function assertHasIntegrity(el: CheerioElement) {
-          const integrity = el.attribs['integrity']
-          expect(integrity).toBeDefined()
-          expect(integrity).toStartWith('sha256-')
-
-          const src = el.attribs['src']
-          expect(src).toBeDefined()
-
-          files.set(src, integrity)
-        }
+            // Find all the script tags without src attributes and with nonce
+            // attributes.
+            const elements = $('script[nonce]:not([src])')
+
+            // Expect there to be none.
+            expect(elements.length).toBe(0)
+          }
+        })
 
-        // <head> scripts are most entrypoint scripts, polyfills, and flight loaded scripts.
-        // Since we currently cannot assert integrity on flight loaded scripts (they do not have it)
-        // We have to target specific expected entrypoint/polyfill scripts and assert them directly
-        const mainScript = $(
-          'head script[src^="/_next/static/chunks/main-app"]'
-        )
-        expect(mainScript.length).toBe(1)
-        assertHasIntegrity(mainScript.get(0))
-
-        const polyfillsScript = $(
-          'head script[src^="/_next/static/chunks/polyfills"]'
-        )
-        expect(polyfillsScript.length).toBe(1)
-        assertHasIntegrity(polyfillsScript.get(0))
-
-        // body scripts should include just the bootstrap script. We assert that all body
-        // scripts have integrity because we don't expect any flight loaded scripts to appear
-        // here
-        const bodyScripts = $('body script[src]')
-        expect(bodyScripts.length).toBeGreaterThan(0)
-        bodyScripts.each((i, el) => {
-          assertHasIntegrity(el)
+        it('includes a nonce value with inline scripts when Content-Security-Policy header is defined', async () => {
+          // A random nonce value, base64 encoded.
+          const nonce = 'cmFuZG9tCg=='
+
+          // Validate all the cases where we could parse the nonce.
+          const policies = [
+            `script-src 'nonce-${nonce}'`, // base case
+            `   script-src   'nonce-${nonce}' `, // extra space added around sources and directive
+            `style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
+            `script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
+            `default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
+            `default-src 'nonce-${nonce}'`, // fallback case
+          ]
+
+          for (const policy of policies) {
+            const $ = await renderWithPolicy(policy)
+
+            // Find all the script tags without src attributes.
+            const elements = $('script:not([src])')
+
+            // Expect there to be at least 1 script tag without a src attribute.
+            expect(elements.length).toBeGreaterThan(0)
+
+            // Expect all inline scripts to have the nonce value.
+            elements.each((i, el) => {
+              expect(el.attribs['nonce']).toBe(nonce)
+            })
+          }
         })
 
-        // For each script tag, ensure that the integrity attribute is the
-        // correct hash of the script tag.
-        for (const [src, integrity] of files) {
-          const res = await next.fetch(src)
-          expect(res.status).toBe(200)
-          const content = await res.text()
+        it('includes a nonce value with inline scripts when Content-Security-Policy-Report-Only header is defined', async () => {
+          // A random nonce value, base64 encoded.
+          const nonce = 'cmFuZG9tCg=='
+
+          // Validate all the cases where we could parse the nonce.
+          const policies = [
+            `script-src 'nonce-${nonce}'`, // base case
+            `   script-src   'nonce-${nonce}' `, // extra space added around sources and directive
+            `style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
+            `script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
+            `default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
+            `default-src 'nonce-${nonce}'`, // fallback case
+          ]
+
+          for (const policy of policies) {
+            const $ = await renderWithPolicy(policy, true)
+
+            // Find all the script tags without src attributes.
+            const elements = $('script:not([src])')
+
+            // Expect there to be at least 1 script tag without a src attribute.
+            expect(elements.length).toBeGreaterThan(0)
+
+            // Expect all inline scripts to have the nonce value.
+            elements.each((i, el) => {
+              expect(el.attribs['nonce']).toBe(nonce)
+            })
+          }
+        })
 
-          const hash = crypto
-            .createHash('sha256')
-            .update(content)
-            .digest()
-            .toString('base64')
+        it('includes a nonce value with bootstrap scripts when Content-Security-Policy header is defined', async () => {
+          // A random nonce value, base64 encoded.
+          const nonce = 'cmFuZG9tCg=='
+
+          // Validate all the cases where we could parse the nonce.
+          const policies = [
+            `script-src 'nonce-${nonce}'`, // base case
+            `   script-src   'nonce-${nonce}' `, // extra space added around sources and directive
+            `style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives
+            `script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces
+            `default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case
+            `default-src 'nonce-${nonce}'`, // fallback case
+          ]
+
+          for (const policy of policies) {
+            const $ = await renderWithPolicy(policy)
+
+            // Find all the script tags without src attributes.
+            const elements = $('script[src]')
+
+            // Expect there to be at least 2 script tag with a src attribute.
+            // The main chunk and the webpack runtime.
+            expect(elements.length).toBeGreaterThan(1)
+
+            // Expect all inline scripts to have the nonce value.
+            elements.each((i, el) => {
+              expect(el.attribs['nonce']).toBe(nonce)
+            })
+          }
+        })
 
-          expect(integrity).toEndWith(hash)
-        }
-      })
+        it('includes an integrity attribute on scripts', async () => {
+          // pages router doesn't do integrity attribute yet
+          if (runtime === 'pages') return
+
+          const $ = await next.render$(`/${runtime}`)
+          // Currently webpack chunks loaded via flight runtime do not get integrity
+          // hashes. This was previously unobservable in this test because these scripts
+          // are inserted by the webpack runtime and immediately removed from the document.
+          // However with the advent of preinitialization of chunks used during SSR there are
+          // some script tags for flight loaded chunks that will be part of the initial HTML
+          // but do not have integrity hashes. Flight does not currently support a way to
+          // provide integrity hashes for these chunks. When this is addressed in React upstream
+          // we can revisit this tests assertions and start to ensure it actually applies to
+          // all SSR'd scripts. For now we will look for known entrypoint scripts and assume
+          // everything else in the <head> is part of flight loaded chunks
+
+          // Collect all the scripts with integrity hashes so we can verify them.
+          const files: Map<string, string> = new Map()
+
+          function assertHasIntegrity(el: CheerioElement) {
+            const integrity = el.attribs['integrity']
+            expect(integrity).toBeDefined()
+            expect(integrity).toStartWith('sha256-')
+
+            const src = el.attribs['src']
+            expect(src).toBeDefined()
+
+            files.set(src, integrity)
+          }
+
+          // <head> scripts are most entrypoint scripts, polyfills, and flight loaded scripts.
+          // Since we currently cannot assert integrity on flight loaded scripts (they do not have it)
+          // We have to target specific expected entrypoint/polyfill scripts and assert them directly
+          const mainScript = $(
+            `head script[src^="/_next/static/chunks/main-app"]`
+          )
+          expect(mainScript.length).toBe(1)
+          assertHasIntegrity(mainScript.get(0))
+
+          const polyfillsScript = $(
+            'head script[src^="/_next/static/chunks/polyfills"]'
+          )
+          expect(polyfillsScript.length).toBe(1)
+          assertHasIntegrity(polyfillsScript.get(0))
+
+          // body scripts should include just the bootstrap script. We assert that all body
+          // scripts have integrity because we don't expect any flight loaded scripts to appear
+          // here
+          const bodyScripts = $('body script[src]')
+          expect(bodyScripts.length).toBeGreaterThan(0)
+          bodyScripts.each((i, el) => {
+            assertHasIntegrity(el)
+          })
 
-      it('throws when escape characters are included in nonce', async () => {
-        const res = await fetchWithPolicy(
-          `script-src 'nonce-"><script></script>"'`
-        )
+          // For each script tag, ensure that the integrity attribute is the
+          // correct hash of the script tag.
+          for (const [src, integrity] of files) {
+            const res = await next.fetch(src)
+            expect(res.status).toBe(200)
+            const content = await res.text()
+
+            const hash = crypto
+              .createHash('sha256')
+              .update(content)
+              .digest()
+              .toString('base64')
+
+            expect(integrity).toEndWith(hash)
+          }
+        })
 
-        if (runtime === 'node' && process.env.__NEXT_EXPERIMENTAL_PPR) {
-          expect(res.status).toBe(200)
-        } else {
-          expect(res.status).toBe(500)
-        }
-      })
-    })
+        it('throws when escape characters are included in nonce', async () => {
+          const res = await fetchWithPolicy(
+            `script-src 'nonce-"><script></script>"'`
+          )
+
+          if (runtime === 'node' && process.env.__NEXT_EXPERIMENTAL_PPR) {
+            expect(res.status).toBe(200)
+          } else {
+            expect(res.status).toBe(500)
+          }
+        })
+      }
+    )
   }
 )