Next.js Cache Poisoning
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
### Impact By sending a crafted HTTP request, it is possible to poison the cache of a non-dynamic server-side rendered route in the pages router (this does not affect the app router). When this crafted request is sent it could coerce Next.js to cache a route that is meant to not be cached and send a `Cache-Control: s-maxage=1, stale-while-revalidate` header which some upstream CDNs may cache as well. To be potentially affected all of the following must apply: - Next.js between 13.5.1 and 14.2.9 - Using pages router - Using non-dynamic server-side rendered routes e.g. `pages/dashboard.tsx` not `pages/blog/[slug].tsx` The below configurations are unaffected: - Deployments using only app router - Deployments on [Vercel](https://vercel.com/) are not affected ### Patches This vulnerability was resolved in Next.js v13.5.7, v14.2.10, and later. We recommend upgrading regardless of whether you can reproduce the issue or not. ### Workarounds There are no official or recommended workarounds for this issue, we recommend that users patch to a safe version. #### Credits - Allam Rachid (zhero_) - Henry Chen
The fix
Remove invalid fallback revalidate value (#69990)
packages/next/src/server/base-server.ts+1 −4
@@ -2573,10 +2573,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {return {...result,-revalidate:-result.revalidate !== undefined-? result.revalidate-: /* default to minimum revalidate (this should be an invariant) */ 1,+revalidate: result.revalidate,}},{
packages/next/src/server/render.tsx+1 −0
@@ -1089,6 +1089,7 @@ export async function renderToHTMLImpl(}))canAccessRes = false+metadata.revalidate = 0} catch (serverSidePropsError: any) {// remove not found error code to prevent triggering legacy// 404 rendering
test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts+10 −6
@@ -345,12 +345,16 @@ describe('required server files i18n', () => {expect(isNaN(data2.random)).toBe(false)expect(data2.random).not.toBe(data.random)-const html3 = await renderViaHTTP(appPort, '/some-other-path', undefined, {-headers: {-'x-matched-path': '/dynamic/[slug]?slug=%5Bslug%5D.json',-'x-now-route-matches': '1=second&nxtPslug=second',-},-})+const html3 = await renderViaHTTP(+appPort,+'/some-other-path?nxtPslug=second',+undefined,+{+headers: {+'x-matched-path': '/dynamic/[slug]?slug=%5Bslug%5D.json',+},+}+)const $3 = cheerio.load(html3)const data3 = JSON.parse($3('#props').text())
test/production/standalone-mode/required-server-files/required-server-files.test.ts+10 −6
@@ -622,12 +622,16 @@ describe('required server files', () => {expect(isNaN(data2.random)).toBe(false)expect(data2.random).not.toBe(data.random)-const html3 = await renderViaHTTP(appPort, '/some-other-path', undefined, {-headers: {-'x-matched-path': '/dynamic/[slug]',-'x-now-route-matches': '1=second&nxtPslug=second',-},-})+const html3 = await renderViaHTTP(+appPort,+'/some-other-path?nxtPslug=second',+undefined,+{+headers: {+'x-matched-path': '/dynamic/[slug]',+},+}+)const $3 = cheerio.load(html3)const data3 = JSON.parse($3('#props').text())
Remove invalid fallback revalidate value (#69990)
packages/next/src/server/base-server.ts+1 −4
@@ -2385,10 +2385,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {return {...result,-revalidate:-result.revalidate !== undefined-? result.revalidate-: /* default to minimum revalidate (this should be an invariant) */ 1,+revalidate: result.revalidate,}},{
packages/next/src/server/render.tsx+1 −0
@@ -1067,6 +1067,7 @@ export async function renderToHTMLImpl(}))canAccessRes = false+renderResultMeta.revalidate = 0} catch (serverSidePropsError: any) {// remove not found error code to prevent triggering legacy// 404 rendering
test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts+10 −6
@@ -344,12 +344,16 @@ describe('required server files i18n', () => {expect(isNaN(data2.random)).toBe(false)expect(data2.random).not.toBe(data.random)-const html3 = await renderViaHTTP(appPort, '/some-other-path', undefined, {-headers: {-'x-matched-path': '/dynamic/[slug]?slug=%5Bslug%5D.json',-'x-now-route-matches': '1=second&nxtPslug=second',-},-})+const html3 = await renderViaHTTP(+appPort,+'/some-other-path?nxtPslug=second',+undefined,+{+headers: {+'x-matched-path': '/dynamic/[slug]?slug=%5Bslug%5D.json',+},+}+)const $3 = cheerio.load(html3)const data3 = JSON.parse($3('#props').text())
test/production/standalone-mode/required-server-files/required-server-files.test.ts+10 −6
@@ -617,12 +617,16 @@ describe('required server files', () => {expect(isNaN(data2.random)).toBe(false)expect(data2.random).not.toBe(data.random)-const html3 = await renderViaHTTP(appPort, '/some-other-path', undefined, {-headers: {-'x-matched-path': '/dynamic/[slug]',-'x-now-route-matches': '1=second&nxtPslug=second',-},-})+const html3 = await renderViaHTTP(+appPort,+'/some-other-path?nxtPslug=second',+undefined,+{+headers: {+'x-matched-path': '/dynamic/[slug]',+},+}+)const $3 = cheerio.load(html3)const data3 = JSON.parse($3('#props').text())
References
- WEBhttps://github.com/vercel/next.js/security/advisories/GHSA-gp8f-8m3g-qvj9
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2024-46982
- WEBhttps://github.com/vercel/next.js/commit/7ed7f125e07ef0517a331009ed7e32691ba403d3
- WEBhttps://github.com/vercel/next.js/commit/bd164d53af259c05f1ab434004bcfdd3837d7cda
- PACKAGEhttps://github.com/vercel/next.js