Skip to content

Commit 544f521

Browse files
fix: Include CSP nonce in next/dynamic preload (#81999)
Co-authored-by: Jiachi Liu <[email protected]>
1 parent 1bfe63a commit 544f521

File tree

10 files changed

+146
-1
lines changed

10 files changed

+146
-1
lines changed

packages/next/src/server/app-render/app-render.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1982,7 +1982,8 @@ export const renderToHTMLOrFlight: AppPageRender = (
19821982
previewModeId: renderOpts.previewProps?.previewModeId,
19831983
})
19841984

1985-
const { isPrefetchRequest, previouslyRevalidatedTags } = parsedRequestHeaders
1985+
const { isPrefetchRequest, previouslyRevalidatedTags, nonce } =
1986+
parsedRequestHeaders
19861987

19871988
let postponedState: PostponedState | null = null
19881989

@@ -2018,6 +2019,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
20182019
isPrefetchRequest,
20192020
buildId: sharedContext.buildId,
20202021
previouslyRevalidatedTags,
2022+
nonce,
20212023
})
20222024

20232025
return workAsyncStorage.run(

packages/next/src/server/app-render/work-async-storage.external.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export interface WorkStore {
9797
Record<string, { files: string[] }>
9898
>
9999
readonly assetPrefix?: string
100+
readonly nonce?: string
100101

101102
cacheComponentsEnabled: boolean
102103
dev: boolean

packages/next/src/server/async-storage/work-store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type WorkStoreContext = {
2020
page: string
2121

2222
isPrefetchRequest?: boolean
23+
nonce?: string
2324
renderOpts: {
2425
cacheLifeProfiles?: { [profile: string]: CacheLife }
2526
incrementalCache?: IncrementalCache
@@ -79,6 +80,7 @@ export function createWorkStore({
7980
isPrefetchRequest,
8081
buildId,
8182
previouslyRevalidatedTags,
83+
nonce,
8284
}: WorkStoreContext): WorkStore {
8385
/**
8486
* Rules of Static & Dynamic HTML:
@@ -135,6 +137,7 @@ export function createWorkStore({
135137
buildId,
136138
reactLoadableManifest: renderOpts?.reactLoadableManifest || {},
137139
assetPrefix: renderOpts?.assetPrefix || '',
140+
nonce,
138141

139142
afterContext: createAfterContext(renderOpts),
140143
cacheComponentsEnabled: renderOpts.experimental.cacheComponents,

packages/next/src/shared/lib/lazy-dynamic/preload-chunks.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ export function PreloadChunks({
5858
href={href}
5959
rel="stylesheet"
6060
as="style"
61+
nonce={workStore.nonce}
6162
/>
6263
)
6364
} else {
6465
// If it's script we use ReactDOM.preload to preload the resources
6566
preload(href, {
6667
as: 'script',
6768
fetchPriority: 'low',
69+
nonce: workStore.nonce,
6870
})
6971
return null
7072
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use client'
2+
3+
export default function DynamicComponent() {
4+
return (
5+
<div id="dynamic-component-loaded">
6+
Dynamic component loaded successfully
7+
</div>
8+
)
9+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { headers } from 'next/headers'
2+
import { Suspense } from 'react'
3+
4+
async function CSPMetatag({ children }) {
5+
const resolvedHeaders = await headers()
6+
const nonce = resolvedHeaders.get('x-nonce') || 'test-nonce'
7+
8+
return (
9+
<meta
10+
httpEquiv="Content-Security-Policy"
11+
content={`default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'`}
12+
/>
13+
)
14+
}
15+
16+
export default async function RootLayout({ children }) {
17+
return (
18+
<html>
19+
<Suspense>
20+
<head>
21+
<CSPMetatag />
22+
</head>
23+
<body>
24+
<div id="csp-nonce-test">{children}</div>
25+
</body>
26+
</Suspense>
27+
</html>
28+
)
29+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client'
2+
3+
import dynamic from 'next/dynamic'
4+
5+
const DynamicComponent = dynamic(() => import('./dynamic-component'), {
6+
loading: () => <div id="loading">Loading...</div>,
7+
})
8+
9+
export default function CSPNoncePage() {
10+
return (
11+
<div>
12+
<h1 id="page-title">CSP Nonce Test Page</h1>
13+
<DynamicComponent />
14+
</div>
15+
)
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { NextResponse } from 'next/server'
2+
3+
export function middleware(request) {
4+
const response = NextResponse.next()
5+
const nonce = 'test-nonce'
6+
response.headers.set('x-nonce', nonce)
7+
response.headers.set(
8+
'Content-Security-Policy',
9+
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'`
10+
)
11+
return response
12+
}
13+
14+
export const config = {
15+
matcher: '/:path*',
16+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
3+
describe('next/dynamic with CSP nonce', () => {
4+
const { next } = nextTestSetup({
5+
files: __dirname,
6+
})
7+
8+
it('should include nonce attribute on preload links generated by next/dynamic', async () => {
9+
const $ = await next.render$('/')
10+
11+
// Check that preload links have the nonce attribute
12+
const preloadLinks = $('link[rel="preload"]')
13+
14+
const dynamicPreloadLinks = preloadLinks.filter((_, element) => {
15+
const $element = $(element)
16+
const href = $element.attr('href')
17+
return href && href.includes('_next/static/chunks/')
18+
})
19+
20+
// There should be at least one preload link for dynamic chunks
21+
expect(dynamicPreloadLinks.length).toBeGreaterThan(0)
22+
23+
dynamicPreloadLinks.each((_, element) => {
24+
const $element = $(element)
25+
const href = $element.attr('href')
26+
27+
// Only check preload links for dynamic chunks
28+
if (href && href.includes('_next/static/chunks/')) {
29+
expect($element.attr('nonce')).toBe('test-nonce')
30+
}
31+
})
32+
})
33+
34+
it('should not generate CSP violations when using next/dynamic with nonce', async () => {
35+
const browser = await next.browser('/')
36+
37+
// Wait for the dynamic component to load
38+
await browser.waitForElementByCss('#dynamic-component-loaded')
39+
40+
// OPTIMIZATION: Get both text values concurrently
41+
const [pageTitle, componentText] = await Promise.all([
42+
browser.elementByCss('#page-title').then((el) => el.text()),
43+
browser.elementByCss('#dynamic-component-loaded').then((el) => el.text()),
44+
])
45+
46+
expect(pageTitle).toBe('CSP Nonce Test Page')
47+
expect(componentText).toBe('Dynamic component loaded successfully')
48+
49+
// Check for CSP violations in Chrome (most expensive operation)
50+
if (global.browserName === 'chrome') {
51+
const logs = await browser.log()
52+
const cspViolations = logs.filter(
53+
(log) =>
54+
log.source === 'security' &&
55+
log.message.includes('Content Security Policy')
56+
)
57+
58+
expect(cspViolations).toEqual([])
59+
}
60+
})
61+
})
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
module.exports = nextConfig

0 commit comments

Comments
 (0)