Next.js DoS vulnerability via cache poisoning
Description
Next.js is a React framework for building full-stack web applications. From versions 15.0.4-canary.51 to before 15.1.8, a cache poisoning bug leading to a Denial of Service (DoS) condition was found in Next.js. This issue does not impact customers hosted on Vercel. Under certain conditions, this issue may allow a HTTP 204 response to be cached for static pages, leading to the 204 response being served to all users attempting to access the page. This issue has been addressed in version 15.1.8.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
nextnpm | >= 15.0.4-canary.51, < 15.1.8 | 15.1.8 |
Affected products
1Patches
2a15b974ed707[backport]: properly gate segmentCache branch in base-server (#79505)
3 files changed · +15 −12
packages/next/src/server/base-server.ts+13 −10 modified@@ -3054,7 +3054,11 @@ export default abstract class Server< } ) - if (isPrefetchRSCRequest && typeof segmentPrefetchHeader === 'string') { + if ( + isRoutePPREnabled && + isPrefetchRSCRequest && + typeof segmentPrefetchHeader === 'string' + ) { // This is a prefetch request issued by the client Segment Cache. These // should never reach the application layer (lambda). We should either // respond from the cache (HIT) or respond with 204 No Content (MISS). @@ -3090,15 +3094,14 @@ export default abstract class Server< // segment), we should *always* respond with a tree, even if PPR // is disabled. res.statusCode = 204 - if (isRoutePPREnabled) { - // Set a header to indicate that PPR is enabled for this route. This - // lets the client distinguish between a regular cache miss and a cache - // miss due to PPR being disabled. - // NOTE: Theoretically, when PPR is enabled, there should *never* be - // a cache miss because we should generate a fallback route. So this - // is mostly defensive. - res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') - } + + // Set a header to indicate that PPR is enabled for this route. This + // lets the client distinguish between a regular cache miss and a cache + // miss due to PPR being disabled. + // NOTE: Theoretically, when PPR is enabled, there should *never* be + // a cache miss because we should generate a fallback route. So this + // is mostly defensive. + res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') return { type: 'rsc', body: RenderResult.fromStatic(''),
test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts+2 −1 modified@@ -1,6 +1,7 @@ import { nextTestSetup } from 'e2e-utils' -describe('segment cache (incremental opt in)', () => { +// Backport Note: This test is skipped as it's only currently usable in the experimental release channel. +describe.skip('segment cache (incremental opt in)', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: __dirname, skipDeployment: true,
test/e2e/pages-performance-mark/pages/_document.js+0 −1 modified@@ -3,7 +3,6 @@ import { Html, Head, Main, NextScript } from 'next/document' export default function Document() { return ( <Html> - <Head /> <Head /> <body> <Main />
16bfce64ef21[Segment Cache] Respond with 204 on cache miss (#73649)
11 files changed · +153 −48
packages/next/src/client/components/segment-cache/cache.ts+19 −5 modified@@ -500,8 +500,17 @@ async function fetchRouteOnCacheMiss( const nextUrl = key.nextUrl try { const response = await fetchSegmentPrefetchResponse(href, '/_tree', nextUrl) - if (!response || !response.ok || !response.body) { - // Received an unexpected response. + if ( + !response || + !response.ok || + // 204 is a Cache miss. Though theoretically this shouldn't happen when + // PPR is enabled, because we always respond to route tree requests, even + // if it needs to be blockingly generated on demand. + response.status === 204 || + !response.body + ) { + // Server responded with an error, or with a miss. We should still cache + // the response, but we can try again after 10 seconds. rejectRouteCacheEntry(entry, Date.now() + 10 * 1000) return } @@ -594,9 +603,14 @@ async function fetchSegmentEntryOnCacheMiss( accessToken === '' ? segmentPath : `${segmentPath}.${accessToken}`, routeKey.nextUrl ) - if (!response || !response.ok || !response.body) { - // Server responded with an error. We should still cache the response, but - // we can try again after 10 seconds. + if ( + !response || + !response.ok || + response.status === 204 || // Cache miss + !response.body + ) { + // Server responded with an error, or with a miss. We should still cache + // the response, but we can try again after 10 seconds. rejectSegmentCacheEntry(segmentCacheEntry, Date.now() + 10 * 1000) return }
packages/next/src/server/base-server.ts+47 −41 modified@@ -3044,49 +3044,55 @@ export default abstract class Server< } ) - if ( - isRoutePPREnabled && - isPrefetchRSCRequest && - typeof segmentPrefetchHeader === 'string' - ) { - if (cacheEntry?.value?.kind === CachedRouteKind.APP_PAGE) { - // This is a prefetch request for an individual segment's static data. - // Unless the segment is fully dynamic, the data should have already been - // loaded into the cache, when the page itself was generated. So we should - // always either return the cache entry. If no cache entry is available, - // it's a 404 — either the segment is fully dynamic, or an invalid segment - // path was requested. - if (cacheEntry.value.segmentData) { - const matchedSegment = cacheEntry.value.segmentData.get( - segmentPrefetchHeader - ) - if (matchedSegment !== undefined) { - return { - type: 'rsc', - body: RenderResult.fromStatic(matchedSegment), - // TODO: Eventually this should use revalidate time of the - // individual segment, not the whole page. - revalidate: cacheEntry.revalidate, - } + if (isPrefetchRSCRequest && typeof segmentPrefetchHeader === 'string') { + // This is a prefetch request issued by the client Segment Cache. These + // should never reach the application layer (lambda). We should either + // respond from the cache (HIT) or respond with 204 No Content (MISS). + if ( + cacheEntry !== null && + // This is always true at runtime but is needed to refine the type + // of cacheEntry.value to CachedAppPageValue, because the outer + // ResponseCacheEntry is not a discriminated union. + cacheEntry.value?.kind === CachedRouteKind.APP_PAGE && + cacheEntry.value.segmentData + ) { + const matchedSegment = cacheEntry.value.segmentData.get( + segmentPrefetchHeader + ) + if (matchedSegment !== undefined) { + // Cache hit + return { + type: 'rsc', + body: RenderResult.fromStatic(matchedSegment), + // TODO: Eventually this should use revalidate time of the + // individual segment, not the whole page. + revalidate: cacheEntry.revalidate, } } - // If the segment is not found, return a 404. Since this is an RSC - // request, there's no reason to render a 404 page; just return an - // empty response. - res.statusCode = 404 - return { - type: 'rsc', - body: RenderResult.fromStatic(''), - revalidate: cacheEntry.revalidate, - } - } else { - // Segment prefetches should never reach the application layer. If - // there's no cache entry for this page, it's a 404. - res.statusCode = 404 - return { - type: 'rsc', - body: RenderResult.fromStatic(''), - } + } + + // Cache miss. Either a cache entry for this route has not been generated, + // or there's no match for the requested segment. Regardless, respond with + // a 204 No Content. We don't bother to respond with 404 in cases where + // the segment does not exist, because these requests are only issued by + // the client cache. + // TODO: If this is a request for the route tree (the special /_tree + // segment), we should *always* respond with a tree, even if PPR + // is disabled. + res.statusCode = 204 + if (isRoutePPREnabled) { + // Set a header to indicate that PPR is enabled for this route. This + // lets the client distinguish between a regular cache miss and a cache + // miss due to PPR being disabled. + // NOTE: Theoretically, when PPR is enabled, there should *never* be + // a cache miss because we should generate a fallback route. So this + // is mostly defensive. + res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') + } + return { + type: 'rsc', + body: RenderResult.fromStatic(''), + revalidate: cacheEntry?.revalidate, } }
test/e2e/app-dir/ppr-navigations/simple/per-segment-prefetching.test.ts+2 −2 modified@@ -64,9 +64,9 @@ describe('per segment prefetching', () => { expect(childResponseText).toInclude('"rsc"') }) - it('respond with 404 if the segment does not have prefetch data', async () => { + it('respond with 204 if the segment does not have prefetch data', async () => { const response = await prefetch('/en', '/does-not-exist') - expect(response.status).toBe(404) + expect(response.status).toBe(204) const responseText = await response.text() expect(responseText.trim()).toBe('') })
test/e2e/app-dir/segment-cache/incremental-opt-in/app/layout.tsx+11 −0 added@@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <html lang="en"> + <body>{children}</body> + </html> + ) +}
test/e2e/app-dir/segment-cache/incremental-opt-in/app/page.tsx+19 −0 added@@ -0,0 +1,19 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <ul> + <li> + <Link href="/ppr-enabled">Page with PPR enabled</Link> + </li> + <li> + <Link href="/ppr-enabled/dynamic-param"> + Page with PPR enabled but has dynamic param + </Link> + </li> + <li> + <Link href="/ppr-disabled">Page with PPR disabled</Link> + </li> + </ul> + ) +}
test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-disabled/page.tsx+3 −0 added@@ -0,0 +1,3 @@ +export default function PPRDisabled() { + return '(intentionally empty)' +}
test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-enabled/[dynamic-param]/page.tsx+3 −0 added@@ -0,0 +1,3 @@ +export default function Page() { + return '(intentionally empty)' +}
test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-enabled/layout.tsx+9 −0 added@@ -0,0 +1,9 @@ +export const experimental_ppr = true + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +}
test/e2e/app-dir/segment-cache/incremental-opt-in/app/ppr-enabled/page.tsx+3 −0 added@@ -0,0 +1,3 @@ +export default function PPREnabled() { + return '(intentionally empty)' +}
test/e2e/app-dir/segment-cache/incremental-opt-in/next.config.js+12 −0 added@@ -0,0 +1,12 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: 'incremental', + dynamicIO: true, + clientSegmentCache: true, + }, +} + +module.exports = nextConfig
test/e2e/app-dir/segment-cache/incremental-opt-in/segment-cache-incremental-opt-in.test.ts+25 −0 added@@ -0,0 +1,25 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('segment cache (incremental opt in)', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + if (isNextDev || skipped) { + test('ppr is disabled', () => {}) + return + } + + // TODO: Replace with e2e test once the client part is implemented + it('prefetch responds with 204 if PPR is disabled for a route', async () => { + await next.browser('/') + const response = await next.fetch('/ppr-disabled', { + headers: { + RSC: '1', + 'Next-Router-Prefetch': '1', + 'Next-Router-Segment-Prefetch': '/_tree', + }, + }) + expect(response.status).toBe(204) + }) +})
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-67rr-84xm-4c7rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-49826ghsaADVISORY
- github.com/vercel/next.js/commit/16bfce64ef2157f2c1dfedcfdb7771bc63103fd2ghsaWEB
- github.com/vercel/next.js/commit/a15b974ed707d63ad4da5b74c1441f5b7b120e93ghsax_refsource_MISCWEB
- github.com/vercel/next.js/releases/tag/v15.1.8ghsax_refsource_MISCWEB
- github.com/vercel/next.js/security/advisories/GHSA-67rr-84xm-4c7rghsax_refsource_CONFIRMWEB
- vercel.com/changelog/cve-2025-49826ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.