Security context
High· 7.5GHSA-gp8f-8m3g-qvj9 CVE-2024-46982CWE-349CWE-639Published Sep 17, 2024

Next.js Cache Poisoning

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

13.5.1 → fixed in 13.5.714.0.0 → fixed in 14.2.10

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)

JJ Kasper· Sep 11, 2024, 09:47 PM+22167ed7f125e0
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)

JJ Kasper· Sep 11, 2024, 09:47 PM+2216bd164d53af
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