XSS (CSP nonce handling, escaping and inline script injection risk)
Description
The commit implements a substantive fix for XSS related to Content Security Policy (CSP) nonce handling and escaping in Next.js server-rendered content. It addresses (a) unsafe extraction of CSP nonces from headers, (b) unsafe embedding of nonces in inline scripts and server-inserted metadata, and (c) escaping of nonce values when emitted into HTML attributes. The changes include: tighter nonce parsing that ignores malformed nonces and validates allowed characters via a regex; escaping of nonce values when injected into script tags or metadata; wrapping JSON payloads with an HTML-safe escape function; and adding tests for malformed nonce handling and RSC-related headers. Collectively, this fixes a class of XSS vectors where an attacker could craft CSP headers or input data to inject or break out of inline scripts.
Proof of Concept
Proof of concept (actionable test and explanation):
Context: CSP nonce handling and inline script insertion in Next.js could be abused if a malicious nonce value or user-controlled payload leaks into an inline script without proper escaping, potentially enabling script injection via attribute breaking or premature script termination. The fix strengthens nonce parsing and escaping and adds tests to prevent these exploits.
Attack vector (pre-fix behavior): An attacker supplies a Content-Security-Policy header with a malformed first nonce that contains executable attributes, e.g. the header:
Content-Security-Policy: script-src 'nonce-" onerror="alert(1)' 'nonce-cmFuZG9tCg=='
If the server (before this fix) picks the first nonce value and embeds it into an inline script tag without proper escaping, the resulting HTML could resemble:
<script nonce="nonce-" onerror="alert(1)"></script>
The onerror handler and the unescaped nonce could lead to XSS when the HTML is parsed by the browser, allowing the attacker to execute arbitrary JavaScript in the context of the victim.
Fix verification (post-fix behavior): The code now:
- ignores malformed nonce values and picks the first valid one (as demonstrated by the test that uses the header containing a malformed nonce followed by a valid one and returns the valid one).
- escapes nonce values in HTML attributes using htmlEscapeAttributeString to prevent breaking out of the attribute.
- escapes JSON payloads inserted into inline scripts via htmlEscapeJsonString to prevent closing the script tag or injecting HTML.
Reproduction steps (conceptual, not dependent on a running app):
1) Build a Next.js 16.2.x app that uses server-injected inline scripts or metadata that include a nonce from the CSP header.
2) Send a request with a CSP header containing a malformed nonce first, followed by a valid nonce:
Content-Security-Policy: script-src 'nonce-" onerror="alert(1)' 'nonce-cmFuZG9tCg=='
3) Observe whether the page ends up with an inline script tag containing an unescaped nonce or an injected event handler (pre-fix behavior).
4) After applying this fix (commit 647d923a3f...), the nonce extraction should ignore the malformed nonce and use the valid one, preventing the injection. The page should include escaped nonce attributes and safe JSON/script content.
Minimal code illustration (conceptual, aligned with patch):
// Before fix (vulnerable path): might directly embed nonce without proper escaping
const scriptTag = `<script nonce=${JSON.stringify(nonce)}>${INLINE_SCRIPT}</script>`;
// After fix (secure path):
const scriptTag = `<script nonce="${htmlEscapeAttributeString(nonce)}">${INLINE_SCRIPT}</script>`;
// Nonce extraction (before fix may throw or accept malformed nonces):
const nonceValue = getScriptNonceFromHeader(headerValue); // now robust, ignores malformed nonces and returns valid one if present
// Inline data payload (risk reduced by escaping JSON):
const payload = htmlEscapeJsonString(JSON.stringify([0, { ...restProps, id }]));
```
This PoC emphasizes that the vulnerability would arise from malformed CSP nonce handling and unsafe embedding, and demonstrates how the fix prevents it by strict parsing and escaping.
Commit Details
Author: Tim Neutkens
Date: 2026-05-07 21:57 UTC
Message:
Cherry-pick ghsa commits to canary (#93614)
## What?
Includes the changes from 16-2.
---------
Co-authored-by: JJ Kasper <jj@jjsweb.site>
Co-authored-by: Josh Story <story@hey.com>
Co-authored-by: Zack Tanner <1939140+ztanner@users.noreply.github.com>
Co-authored-by: Benjamin Woodruff <benjamin.woodruff@vercel.com>
Triage Assessment
Vulnerability Type: XSS
Confidence: HIGH
Reasoning:
The changes introduce proper escaping for nonce/nonces in inline scripts and metadata, adjust CSP nonce extraction to ignore malformed values instead of throwing, and escape attributes in dynamically-inserted scripts. This directly mitigates potential XSS vulnerabilities related to Content Security Policy nonce handling and HTML/script injection within server-rendered metadata and inline scripts.
Verification Assessment
Vulnerability Type: XSS (CSP nonce handling, escaping and inline script injection risk)
Confidence: HIGH
Affected Versions: <=16.2.2 (16.2.x line prior to this patch)
Code Diff
diff --git a/packages/next/src/client/script.tsx b/packages/next/src/client/script.tsx
index e28724e894cc..d1bde2e38207 100644
--- a/packages/next/src/client/script.tsx
+++ b/packages/next/src/client/script.tsx
@@ -6,6 +6,7 @@ import type { ScriptHTMLAttributes } from 'react'
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
import { setAttributesFromProps } from './set-attributes-from-props'
import { requestIdleCallback } from './request-idle-callback'
+import { htmlEscapeJsonString } from '../shared/lib/htmlescape'
const ScriptCache = new Map()
const LoadCache = new Set()
@@ -327,10 +328,9 @@ function Script(props: ScriptProps): JSX.Element | null {
<script
nonce={nonce}
dangerouslySetInnerHTML={{
- __html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
- 0,
- { ...restProps, id },
- ])})`,
+ __html: `(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
+ JSON.stringify([0, { ...restProps, id }])
+ )})`,
}}
/>
)
@@ -351,10 +351,9 @@ function Script(props: ScriptProps): JSX.Element | null {
<script
nonce={nonce}
dangerouslySetInnerHTML={{
- __html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
- src,
- { ...restProps, id },
- ])})`,
+ __html: `(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
+ JSON.stringify([src, { ...restProps, id }])
+ )})`,
}}
/>
)
diff --git a/packages/next/src/pages/_document.tsx b/packages/next/src/pages/_document.tsx
index 117ad774a2df..28269e98e01c 100644
--- a/packages/next/src/pages/_document.tsx
+++ b/packages/next/src/pages/_document.tsx
@@ -14,7 +14,7 @@ import type { NextFontManifest } from '../build/webpack/plugins/next-font-manife
import { getPageFiles } from '../server/get-page-files'
import type { BuildManifest } from '../server/get-page-files'
-import { htmlEscapeJsonString } from '../server/htmlescape'
+import { htmlEscapeJsonString } from '../shared/lib/htmlescape'
import isError from '../lib/is-error'
import {
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index 6878e8fc2395..d8ff3f0b337c 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -78,6 +78,7 @@ import {
} from '../../client/components/app-router-headers'
import { createMetadataContext } from '../../lib/metadata/metadata-context'
import { createRequestStoreForRender } from '../async-storage/request-store'
+import { isRSCRequestHeader } from '../lib/is-rsc-request'
import { createWorkStore } from '../async-storage/work-store'
import {
getAccessFallbackErrorTypeByStatus,
@@ -389,7 +390,7 @@ function parseRequestHeaders(
const isHmrRefresh = headers[NEXT_HMR_REFRESH_HEADER] !== undefined
- const isRSCRequest = headers[RSC_HEADER] !== undefined
+ const isRSCRequest = isRSCRequestHeader(headers[RSC_HEADER])
const shouldProvideFlightRouterState =
isRSCRequest && (!isPrefetchRequest || !options.isRoutePPREnabled)
diff --git a/packages/next/src/server/app-render/get-script-nonce-from-header.test.ts b/packages/next/src/server/app-render/get-script-nonce-from-header.test.ts
new file mode 100644
index 000000000000..38971b77e029
--- /dev/null
+++ b/packages/next/src/server/app-render/get-script-nonce-from-header.test.ts
@@ -0,0 +1,28 @@
+import { getScriptNonceFromHeader } from './get-script-nonce-from-header'
+
+describe('getScriptNonceFromHeader', () => {
+ it('returns the first valid nonce from the script-src directive', () => {
+ expect(
+ getScriptNonceFromHeader(
+ `default-src 'nonce-other'; script-src 'nonce-cmFuZG9tCg=='`
+ )
+ ).toBe('cmFuZG9tCg==')
+ })
+
+ it('ignores malformed nonce values', () => {
+ expect(
+ getScriptNonceFromHeader(`script-src 'nonce-"><script></script>"'`)
+ ).toBeUndefined()
+ expect(
+ getScriptNonceFromHeader(`script-src 'nonce-" onerror="alert(1)'`)
+ ).toBeUndefined()
+ })
+
+ it('skips malformed nonce values and keeps looking for a valid one', () => {
+ expect(
+ getScriptNonceFromHeader(
+ `script-src 'nonce-" onerror="alert(1)' 'nonce-cmFuZG9tCg=='`
+ )
+ ).toBe('cmFuZG9tCg==')
+ })
+})
diff --git a/packages/next/src/server/app-render/get-script-nonce-from-header.tsx b/packages/next/src/server/app-render/get-script-nonce-from-header.tsx
index 63b20a005fc3..45ce83c00ae0 100644
--- a/packages/next/src/server/app-render/get-script-nonce-from-header.tsx
+++ b/packages/next/src/server/app-render/get-script-nonce-from-header.tsx
@@ -1,4 +1,4 @@
-import { ESCAPE_REGEX } from '../htmlescape'
+const CSP_NONCE_SOURCE_REGEX = /^'nonce-([A-Za-z0-9+/_-]+={0,2})'$/
export function getScriptNonceFromHeader(
cspHeaderValue: string
@@ -19,35 +19,13 @@ export function getScriptNonceFromHeader(
return
}
- // Extract the nonce from the directive
- const nonce = directive
- .split(' ')
- // Remove the 'strict-src'/'default-src' string, this can't be the nonce.
- .slice(1)
- .map((source) => source.trim())
- // Find the first source with the 'nonce-' prefix.
- .find(
- (source) =>
- source.startsWith("'nonce-") &&
- source.length > 8 &&
- source.endsWith("'")
- )
- // Grab the nonce by trimming the 'nonce-' prefix.
- ?.slice(7, -1)
+ // Extract the first valid nonce from the directive. Malformed nonces are
+ // ignored so the request can continue without a nonce instead of failing.
+ for (const source of directive.split(/\s+/).slice(1)) {
+ const match = source.trim().match(CSP_NONCE_SOURCE_REGEX)
- // If we could't find the nonce, then we're done.
- if (!nonce) {
- return
- }
-
- // Don't accept the nonce value if it contains HTML escape characters.
- // Technically, the spec requires a base64'd value, but this is just an
- // extra layer.
- if (ESCAPE_REGEX.test(nonce)) {
- throw new Error(
- 'Nonce value from Content-Security-Policy contained HTML escape characters.\nLearn more: https://nextjs.org/docs/messages/nonce-contained-invalid-characters'
- )
+ if (match) {
+ return match[1]
+ }
}
-
- return nonce
}
diff --git a/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts
new file mode 100644
index 000000000000..f6b386763d03
--- /dev/null
+++ b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts
@@ -0,0 +1,12 @@
+import { createServerInsertedMetadata } from './create-server-inserted-metadata'
+
+describe('createServerInsertedMetadata', () => {
+ it('escapes nonce attribute values in raw HTML output', async () => {
+ const getServerInsertedMetadata =
+ createServerInsertedMetadata(`" onerror="alert(1)`)
+
+ await expect(getServerInsertedMetadata()).resolves.toContain(
+ '<script nonce="" onerror="alert(1)">'
+ )
+ })
+})
diff --git a/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx
index af42e04df397..44b9f53e4a0a 100644
--- a/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx
+++ b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx
@@ -1,3 +1,5 @@
+import { htmlEscapeAttributeString } from '../../../shared/lib/htmlescape'
+
/**
* For chromium based browsers (Chrome, Edge, etc.) and Safari,
* icons need to stay under <head> to be picked up by the browser.
@@ -15,6 +17,8 @@ export function createServerInsertedMetadata(nonce: string | undefined) {
}
inserted = true
- return `<script ${nonce ? `nonce="${nonce}"` : ''}>${REINSERT_ICON_SCRIPT}</script>`
+ return `<script${
+ nonce ? ` nonce="${htmlEscapeAttributeString(nonce)}"` : ''
+ }>${REINSERT_ICON_SCRIPT}</script>`
}
}
diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts
index 554246405494..5e4e80b26253 100644
--- a/packages/next/src/server/app-render/stream-ops.node.ts
+++ b/packages/next/src/server/app-render/stream-ops.node.ts
@@ -31,7 +31,10 @@ import {
import { indexOfUint8Array } from '../stream-utils/uint8array-helpers'
import { ENCODED_TAGS } from '../stream-utils/encoded-tags'
import { MISSING_ROOT_TAGS_ERROR } from '../../shared/lib/errors/constants'
-import { htmlEscapeJsonString } from '../htmlescape'
+import {
+ htmlEscapeAttributeString,
+ htmlEscapeJsonString,
+} from '../../shared/lib/htmlescape'
import { createInlinedDataReadableStream } from './use-flight-response'
import type { AnyStream as AnyStreamType } from './app-render-prerender-utils'
import { DetachedPromise } from '../../lib/detached-promise'
@@ -937,7 +940,7 @@ export function createNodeInlinedDataStream(
formState: unknown | null
): AnyStream {
const startScriptTag = nonce
- ? `<script nonce=${JSON.stringify(nonce)}>`
+ ? `<script nonce="${htmlEscapeAttributeString(nonce)}">`
: '<script>'
const dataStream = webToReadable(source)
diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx
index 5c871bfe5534..c3622317da0e 100644
--- a/packages/next/src/server/app-render/use-flight-response.tsx
+++ b/packages/next/src/server/app-render/use-flight-response.tsx
@@ -1,7 +1,10 @@
import type { BinaryStreamOf } from './app-render'
import type { Readable } from 'node:stream'
-import { htmlEscapeJsonString } from '../htmlescape'
+import {
+ htmlEscapeAttributeString,
+ htmlEscapeJsonString,
+} from '../../shared/lib/htmlescape'
import { workUnitAsyncStorage } from './work-unit-async-storage.external'
import { InvariantError } from '../../shared/lib/invariant-error'
import { getClientReferenceManifest } from './manifests-singleton'
@@ -165,7 +168,7 @@ export function createInlinedDataReadableStream(
formState: unknown | null
): ReadableStream<Uint8Array> {
const startScriptTag = nonce
- ? `<script nonce=${JSON.stringify(nonce)}>`
+ ? `<script nonce="${htmlEscapeAttributeString(nonce)}">`
: '<script>'
const flightReader = flightStream.getReader()
diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts
index f704829365c0..b807fb235fc1 100644
--- a/packages/next/src/server/base-server.ts
+++ b/packages/next/src/server/base-server.ts
@@ -48,6 +48,7 @@ import type { InstrumentationModule } from './instrumentation/types'
import * as path from 'path'
import { format as formatUrl } from 'url'
import { formatHostname } from './lib/format-hostname'
+import { isRSCRequestHeader } from './lib/is-rsc-request'
import {
APP_PATHS_MANIFEST,
NEXT_BUILTIN_DOCUMENT,
@@ -660,7 +661,7 @@ export default abstract class Server<
stripFlightHeaders(req.headers)
return false
- } else if (req.headers[RSC_HEADER] === '1') {
+ } else if (isRSCRequestHeader(req.headers[RSC_HEADER])) {
addRequestMeta(req, 'isRSCRequest', true)
if (req.headers[NEXT_ROUTER_PREFETCH_HEADER] === '1') {
@@ -2235,7 +2236,7 @@ export default abstract class Server<
// even during a locked scope, with blocking happening on the client side.
const hasInstantTestCookie =
exposeTestingApi &&
- req.headers[RSC_HEADER] === undefined &&
+ !isRSCRequestHeader(req.headers[RSC_HEADER]) &&
typeof req.headers.cookie === 'string' &&
req.headers.cookie.includes(NEXT_INSTANT_TEST_COOKIE + '=') &&
couldSupportPPR
diff --git a/packages/next/src/server/lib/is-rsc-request.test.ts b/packages/next/src/server/lib/is-rsc-request.test.ts
new file mode 100644
index 000000000000..c2d9215d650a
--- /dev/null
+++ b/packages/next/src/server/lib/is-rsc-request.test.ts
@@ -0,0 +1,18 @@
+import { isRSCRequestHeader } from './is-rsc-request'
+
+describe('isRSCRequestHeader', () => {
+ it('returns true for the canonical RSC header value', () => {
+ expect(isRSCRequestHeader('1')).toBe(true)
+ })
+
+ it('returns false for invalid or missing values', () => {
+ expect(isRSCRequestHeader('0')).toBe(false)
+ expect(isRSCRequestHeader(undefined)).toBe(false)
+ expect(isRSCRequestHeader(null)).toBe(false)
+ })
+
+ it('returns false for repeated header values', () => {
+ expect(isRSCRequestHeader(['1'])).toBe(false)
+ expect(isRSCRequestHeader(['1', '1'])).toBe(false)
+ })
+})
diff --git a/packages/next/src/server/lib/is-rsc-request.ts b/packages/next/src/server/lib/is-rsc-request.ts
new file mode 100644
index 000000000000..52e71d0a25fc
--- /dev/null
+++ b/packages/next/src/server/lib/is-rsc-request.ts
@@ -0,0 +1,9 @@
+/**
+ * Normalizes the raw RSC header value. Only the literal string "1" is treated
+ * as a valid RSC request marker; malformed or repeated values are ignored.
+ */
+export function isRSCRequestHeader(
+ value: string | string[] | null | undefined
+): boolean {
+ return value === '1'
+}
diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts
index db4e1fbdc18a..b9d3957b87ca 100644
--- a/packages/next/src/server/lib/router-server.ts
+++ b/packages/next/src/server/lib/router-server.ts
@@ -895,12 +895,13 @@ export async function initialize(opts: {
)
},
})
- const { matchedOutput, parsedUrl } = await resolveRoutes({
- req,
- res,
- isUpgradeReq: true,
- signal: signalFromNodeResponse(socket),
- })
+ const { finished, matchedOutput, parsedUrl, statusCode } =
+ await resolveRoutes({
+ req,
+ res,
+ isUpgradeReq: true,
+ signal: signalFromNodeResponse(socket),
+ })
// TODO: allow upgrade requests to pages/app paths?
// this was not previously supported
@@ -908,8 +909,12 @@ export async function initialize(opts: {
return socket.end()
}
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+
+ return socket.end()
}
// If there's no matched output, we don't handle the request as user's
diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts
index 13757b326e01..1d4856af176d
... [truncated]