Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle nonce on Next.js injected script/link tags #65508

Merged
merged 4 commits into from
May 10, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 14 additions & 9 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export type AppRenderContext = AppRenderBaseContext & {
flightDataRendererErrorHandler: ErrorHandler
serverComponentsErrorHandler: ErrorHandler
isNotFoundPath: boolean
nonce: string | undefined
res: BaseNextResponse
}

Expand Down Expand Up @@ -362,6 +363,7 @@ async function generateFlight(
ctx.clientReferenceManifest.clientModules,
{
onError: ctx.flightDataRendererErrorHandler,
nonce: ctx.nonce,
}
)

Expand Down Expand Up @@ -841,6 +843,15 @@ async function renderToHTMLOrFlightImpl(
parsedFlightRouterState
)

// Get the nonce from the incoming request if it has one.
const csp =
req.headers['content-security-policy'] ||
req.headers['content-security-policy-report-only']
let nonce: string | undefined
if (csp && typeof csp === 'string') {
nonce = getScriptNonceFromHeader(csp)
}

const ctx: AppRenderContext = {
...baseCtx,
getDynamicParamFromSegment,
Expand All @@ -859,6 +870,7 @@ async function renderToHTMLOrFlightImpl(
flightDataRendererErrorHandler,
serverComponentsErrorHandler,
isNotFoundPath,
nonce,
res,
}

Expand All @@ -875,15 +887,6 @@ async function renderToHTMLOrFlightImpl(
? createFlightDataResolver(ctx)
: null

// Get the nonce from the incoming request if it has one.
const csp =
req.headers['content-security-policy'] ||
req.headers['content-security-policy-report-only']
let nonce: string | undefined
if (csp && typeof csp === 'string') {
nonce = getScriptNonceFromHeader(csp)
}

const validateRootLayout = dev

const { HeadManagerContext } =
Expand Down Expand Up @@ -943,6 +946,7 @@ async function renderToHTMLOrFlightImpl(
clientReferenceManifest.clientModules,
{
onError: serverComponentsErrorHandler,
nonce,
}
)

Expand Down Expand Up @@ -1279,6 +1283,7 @@ async function renderToHTMLOrFlightImpl(
clientReferenceManifest.clientModules,
{
onError: serverComponentsErrorHandler,
nonce,
}
)

Expand Down
27 changes: 22 additions & 5 deletions packages/next/src/server/app-render/get-layer-assets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,21 @@ export function getLayerAssets({
const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(fontFilename)![1]
const type = `font/${ext}`
const href = `${ctx.assetPrefix}/_next/${encodeURIPath(fontFilename)}`
ctx.componentMod.preloadFont(href, type, ctx.renderOpts.crossOrigin)
ctx.componentMod.preloadFont(
href,
type,
ctx.renderOpts.crossOrigin,
ctx.nonce
)
}
} else {
try {
let url = new URL(ctx.assetPrefix)
ctx.componentMod.preconnect(url.origin, 'anonymous')
ctx.componentMod.preconnect(url.origin, 'anonymous', ctx.nonce)
} catch (error) {
// assetPrefix must not be a fully qualified domain name. We assume
// we should preconnect to same origin instead
ctx.componentMod.preconnect('/', 'anonymous')
ctx.componentMod.preconnect('/', 'anonymous', ctx.nonce)
}
}
}
Expand All @@ -78,7 +83,11 @@ export function getLayerAssets({
const precedence =
process.env.NODE_ENV === 'development' ? 'next_' + href : 'next'

ctx.componentMod.preloadStyle(fullHref, ctx.renderOpts.crossOrigin)
ctx.componentMod.preloadStyle(
fullHref,
ctx.renderOpts.crossOrigin,
ctx.nonce
)

return (
<link
Expand All @@ -88,6 +97,7 @@ export function getLayerAssets({
precedence={precedence}
crossOrigin={ctx.renderOpts.crossOrigin}
key={index}
nonce={ctx.nonce}
/>
)
})
Expand All @@ -99,7 +109,14 @@ export function getLayerAssets({
href
)}${getAssetQueryString(ctx, true)}`

return <script src={fullSrc} async={true} key={`script-${index}`} />
return (
<script
src={fullSrc}
async={true}
key={`script-${index}`}
nonce={ctx.nonce}
/>
)
})
: []

Expand Down
33 changes: 26 additions & 7 deletions packages/next/src/server/app-render/rsc/preloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,48 @@ Files in the rsc directory are meant to be packaged as part of the RSC graph usi

import ReactDOM from 'react-dom'

export function preloadStyle(href: string, crossOrigin?: string | undefined) {
export function preloadStyle(
href: string,
crossOrigin: string | undefined,
nonce: string | undefined
) {
const opts: any = { as: 'style' }
if (typeof crossOrigin === 'string') {
opts.crossOrigin = crossOrigin
}
if (typeof nonce === 'string') {
opts.nonce = nonce
}
ReactDOM.preload(href, opts)
}

export function preloadFont(
href: string,
type: string,
crossOrigin?: string | undefined
crossOrigin: string | undefined,
nonce: string | undefined
) {
const opts: any = { as: 'font', type }
if (typeof crossOrigin === 'string') {
opts.crossOrigin = crossOrigin
}
if (typeof nonce === 'string') {
opts.nonce = nonce
}
ReactDOM.preload(href, opts)
}

export function preconnect(href: string, crossOrigin?: string | undefined) {
;(ReactDOM as any).preconnect(
href,
typeof crossOrigin === 'string' ? { crossOrigin } : undefined
)
export function preconnect(
href: string,
crossOrigin: string | undefined,
nonce: string | undefined
) {
const opts: any = {}
if (typeof crossOrigin === 'string') {
opts.crossOrigin = crossOrigin
}
if (typeof nonce === 'string') {
opts.nonce = nonce
}
;(ReactDOM as any).preconnect(href, opts)
}
11 changes: 11 additions & 0 deletions test/e2e/app-dir/app/app/script-nonce/with-next-font/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export default function Page() {
return (
<>
<p className={inter.className}>script-nonce</p>
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/app/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,16 @@ describe('app dir - basic', () => {
})
}
})

it('should pass nonce when using next/font', async () => {
const html = await next.render('/script-nonce/with-next-font')
const $ = cheerio.load(html)
const scripts = $('script, link[rel="preload"][as="script"]')

scripts.each((_, element) => {
expect(element.attribs.nonce).toBeTruthy()
})
})
})

describe('data fetch with response over 16KB with chunked encoding', () => {
Expand Down
10 changes: 10 additions & 0 deletions test/e2e/app-dir/app/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,14 @@ export async function middleware(request) {
},
})
}

if (request.nextUrl.pathname === '/script-nonce/with-next-font') {
const nonce = crypto.randomUUID()

return NextResponse.next({
headers: {
'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic';`,
},
})
}
}
1 change: 1 addition & 0 deletions test/turbopack-build-tests-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,7 @@
"app dir - basic next/script should insert preload tags for beforeInteractive and afterInteractive scripts",
"app dir - basic next/script should load stylesheets for next/scripts",
"app dir - basic next/script should pass `nonce`",
"app dir - basic next/script should pass nonce when using next/font",
"app dir - basic next/script should pass on extra props for beforeInteractive scripts with a src prop",
"app dir - basic next/script should pass on extra props for beforeInteractive scripts without a src prop",
"app dir - basic next/script should support next/script and render in correct order",
Expand Down