Security context
Low· 3.7GHSA-r2fc-ccr8-96c4 CVE-2025-49005CWE-444Published Jul 3, 2025

Next.js has a Cache poisoning vulnerability due to omission of the Vary header

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

15.3.0 → fixed in 15.3.3

Details

### Summary A cache poisoning issue in **Next.js App Router >=15.3.0 and < 15.3.3** may have allowed RSC payloads to be cached and served in place of HTML, under specific conditions involving middleware and redirects. This issue has been fixed in **Next.js 15.3.3**. Users on affected versions should **upgrade immediately** and **redeploy** to ensure proper caching behavior. More details: [CVE-2025-49005](https://vercel.com/changelog/cve-2025-49005)

The fix

Revert "[next-server] skip setting vary header for basic

Jude Gao· May 23, 2025, 08:44 PM+55184978c0cff0
packages/next/src/server/base-server.ts+9 3
@@ -104,6 +104,7 @@ import {
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
NEXT_DID_POSTPONE_HEADER,
NEXT_URL,
+ NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_IS_PRERENDER_HEADER,
} from '../client/components/app-router-headers'
import type {
@@ -1981,16 +1982,21 @@ export default abstract class Server<
isAppPath: boolean,
resolvedPathname: string
): void {
+ const baseVaryHeader = `${RSC_HEADER}, ${NEXT_ROUTER_STATE_TREE_HEADER}, ${NEXT_ROUTER_PREFETCH_HEADER}, ${NEXT_ROUTER_SEGMENT_PREFETCH_HEADER}`
+ const isRSCRequest = getRequestMeta(req, 'isRSCRequest') ?? false
+
let addedNextUrlToVary = false
if (isAppPath && this.pathCouldBeIntercepted(resolvedPathname)) {
// Interception route responses can vary based on the `Next-URL` header.
// We use the Vary header to signal this behavior to the client to properly cache the response.
- res.appendHeader('vary', `${NEXT_URL}`)
+ res.appendHeader('vary', `${baseVaryHeader}, ${NEXT_URL}`)
addedNextUrlToVary = true
+ } else if (isAppPath || isRSCRequest) {
+ // We don't need to include `Next-URL` in the Vary header for non-interception routes since it won't affect the response.
+ // We also set this header for pages to avoid caching issues when navigating between pages and app.
+ res.appendHeader('vary', baseVaryHeader)
}
- // For other cases such as App Router requests or RSC requests we don't need to set vary header since we already
- // have the _rsc query with the unique hash value.
if (!addedNextUrlToVary) {
// Remove `Next-URL` from the request headers we determined it wasn't necessary to include in the Vary header.
test/e2e/app-dir/app/index.test.ts+23 0
@@ -323,6 +323,29 @@ describe('app dir - basic', () => {
expect(res.headers.get('Content-Type')).toBe('text/x-component')
})
+ it('should return the `vary` header from edge runtime', async () => {
+ const res = await next.fetch('/dashboard')
+ expect(res.headers.get('x-edge-runtime')).toBe('1')
+ expect(res.headers.get('vary')).toBe(
+ 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch'
+ )
+ })
+
+ if (!isNextDeploy) {
+ it('should return the `vary` header from pages for flight requests', async () => {
+ const res = await next.fetch('/', {
+ headers: {
+ ['RSC'.toString()]: '1',
+ },
+ })
+ expect(res.headers.get('vary')).toBe(
+ isNextDeploy
+ ? 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch'
+ : 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding'
+ )
+ })
+ }
+
it('should pass props from getServerSideProps in root layout', async () => {
const $ = await next.render$('/dashboard')
expect($('title').first().text()).toBe('hello world')
test/e2e/vary-header/test/index.test.ts+12 2
@@ -12,16 +12,21 @@ describe('Vary Header Tests', () => {
expect(res.headers.get('vary')).toContain('Custom-Header')
})
- it('should preserve custom vary header', async () => {
+ it('should preserve custom vary header and append RSC headers in app route handlers', async () => {
const res = await next.fetch('/normal')
const varyHeader = res.headers.get('vary')
// Custom header is preserved
expect(varyHeader).toContain('User-Agent')
expect(res.headers.get('cache-control')).toBe('s-maxage=3600')
+
+ // Next.js internal headers are appended
+ expect(varyHeader).toContain('RSC')
+ expect(varyHeader).toContain('Next-Router-State-Tree')
+ expect(varyHeader).toContain('Next-Router-Prefetch')
})
- it('should preserve middleware vary header', async () => {
+ it('should preserve middleware vary header in combination with route handlers', async () => {
const res = await next.fetch('/normal')
const varyHeader = res.headers.get('vary')
const customHeader = res.headers.get('my-custom-header')
@@ -32,5 +37,10 @@ describe('Vary Header Tests', () => {
// Both middleware and route handler vary headers are preserved
expect(varyHeader).toContain('my-custom-header')
expect(varyHeader).toContain('User-Agent')
+
+ // Next.js internal headers are still present
+ expect(varyHeader).toContain('RSC')
+ expect(varyHeader).toContain('Next-Router-State-Tree')
+ expect(varyHeader).toContain('Next-Router-Prefetch')
})
})
test/e2e/app-dir/app/index.test.ts | 24 +++++++++++-------------
1 file changed, 11 insertions(+), 13 deletions(-)
test/e2e/app-dir/app/index.test.ts+11 13
@@ -331,20 +331,18 @@ describe('app dir - basic', () => {
)
})
- if (!isNextDeploy) {
- it('should return the `vary` header from pages for flight requests', async () => {
- const res = await next.fetch('/', {
- headers: {
- ['RSC'.toString()]: '1',
- },
- })
- expect(res.headers.get('vary')).toBe(
- isNextDeploy
- ? 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch'
- : 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding'
- )
+ it('should return the `vary` header from pages for flight requests', async () => {
+ const res = await next.fetch('/', {
+ headers: {
+ ['RSC'.toString()]: '1',
+ },
})
- }
+ expect(res.headers.get('vary')).toBe(
+ isNextDeploy
+ ? 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch'
+ : 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding'
+ )
+ })
it('should pass props from getServerSideProps in root layout', async () => {
const $ = await next.render$('/dashboard')

Revert "[next-server] skip setting vary header for basic

Jude Gao· May 23, 2025, 08:44 PM+445ec202eccf0
packages/next/src/server/base-server.ts+9 3
@@ -104,6 +104,7 @@ import {
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
NEXT_DID_POSTPONE_HEADER,
NEXT_URL,
+ NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_IS_PRERENDER_HEADER,
} from '../client/components/app-router-headers'
import type {
@@ -1981,16 +1982,21 @@ export default abstract class Server<
isAppPath: boolean,
resolvedPathname: string
): void {
+ const baseVaryHeader = `${RSC_HEADER}, ${NEXT_ROUTER_STATE_TREE_HEADER}, ${NEXT_ROUTER_PREFETCH_HEADER}, ${NEXT_ROUTER_SEGMENT_PREFETCH_HEADER}`
+ const isRSCRequest = getRequestMeta(req, 'isRSCRequest') ?? false
+
let addedNextUrlToVary = false
if (isAppPath && this.pathCouldBeIntercepted(resolvedPathname)) {
// Interception route responses can vary based on the `Next-URL` header.
// We use the Vary header to signal this behavior to the client to properly cache the response.
- res.appendHeader('vary', `${NEXT_URL}`)
+ res.appendHeader('vary', `${baseVaryHeader}, ${NEXT_URL}`)
addedNextUrlToVary = true
+ } else if (isAppPath || isRSCRequest) {
+ // We don't need to include `Next-URL` in the Vary header for non-interception routes since it won't affect the response.
+ // We also set this header for pages to avoid caching issues when navigating between pages and app.
+ res.appendHeader('vary', baseVaryHeader)
}
- // For other cases such as App Router requests or RSC requests we don't need to set vary header since we already
- // have the _rsc query with the unique hash value.
if (!addedNextUrlToVary) {
// Remove `Next-URL` from the request headers we determined it wasn't necessary to include in the Vary header.
test/e2e/app-dir/app/index.test.ts+23 0
@@ -323,6 +323,29 @@ describe('app dir - basic', () => {
expect(res.headers.get('Content-Type')).toBe('text/x-component')
})
+ it('should return the `vary` header from edge runtime', async () => {
+ const res = await next.fetch('/dashboard')
+ expect(res.headers.get('x-edge-runtime')).toBe('1')
+ expect(res.headers.get('vary')).toBe(
+ 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch'
+ )
+ })
+
+ if (!isNextDeploy) {
+ it('should return the `vary` header from pages for flight requests', async () => {
+ const res = await next.fetch('/', {
+ headers: {
+ ['RSC'.toString()]: '1',
+ },
+ })
+ expect(res.headers.get('vary')).toBe(
+ isNextDeploy
+ ? 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch'
+ : 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding'
+ )
+ })
+ }
+
it('should pass props from getServerSideProps in root layout', async () => {
const $ = await next.render$('/dashboard')
expect($('title').first().text()).toBe('hello world')
test/e2e/vary-header/test/index.test.ts+12 2
@@ -12,16 +12,21 @@ describe('Vary Header Tests', () => {
expect(res.headers.get('vary')).toContain('Custom-Header')
})
- it('should preserve custom vary header', async () => {
+ it('should preserve custom vary header and append RSC headers in app route handlers', async () => {
const res = await next.fetch('/normal')
const varyHeader = res.headers.get('vary')
// Custom header is preserved
expect(varyHeader).toContain('User-Agent')
expect(res.headers.get('cache-control')).toBe('s-maxage=3600')
+
+ // Next.js internal headers are appended
+ expect(varyHeader).toContain('RSC')
+ expect(varyHeader).toContain('Next-Router-State-Tree')
+ expect(varyHeader).toContain('Next-Router-Prefetch')
})
- it('should preserve middleware vary header', async () => {
+ it('should preserve middleware vary header in combination with route handlers', async () => {
const res = await next.fetch('/normal')
const varyHeader = res.headers.get('vary')
const customHeader = res.headers.get('my-custom-header')
@@ -32,5 +37,10 @@ describe('Vary Header Tests', () => {
// Both middleware and route handler vary headers are preserved
expect(varyHeader).toContain('my-custom-header')
expect(varyHeader).toContain('User-Agent')
+
+ // Next.js internal headers are still present
+ expect(varyHeader).toContain('RSC')
+ expect(varyHeader).toContain('Next-Router-State-Tree')
+ expect(varyHeader).toContain('Next-Router-Prefetch')
})
})

References