Next.js has a Cache poisoning vulnerability due to omission of the Vary header
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
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
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 = falseif (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 preservedexpect(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 preservedexpect(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
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 = falseif (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 preservedexpect(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 preservedexpect(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
- WEBhttps://github.com/vercel/next.js/security/advisories/GHSA-r2fc-ccr8-96c4
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2025-49005
- WEBhttps://github.com/vercel/next.js/issues/79346
- WEBhttps://github.com/vercel/next.js/pull/79939
- WEBhttps://github.com/vercel/next.js/commit/ec202eccf05820b60c6126d6411fe16766ecc066
- PACKAGEhttps://github.com/vercel/next.js
- WEBhttps://github.com/vercel/next.js/releases/tag/v15.3.3
- WEBhttps://vercel.com/changelog/cve-2025-49005