Security context
High· 7.5GHSA-67rr-84xm-4c7r CVE-2025-49826CWE-444Published Jul 3, 2025

Next.JS vulnerability can lead to DoS via 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

15.0.4-canary.51 → fixed in 15.1.8

Details

### Summary A vulnerability affecting Next.js has been addressed. It impacted versions 15.0.4 through 15.1.8 and involved a cache poisoning bug leading to a Denial of Service (DoS) condition. 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 More details: [CVE-2025-49826](https://vercel.com/changelog/cve-2025-49826) ## Credits - Allam Rachid [zhero;](https://zhero-web-sec.github.io/research-and-things/) - Allam Yasser (inzo)

The fix

[Segment Cache] Respond with 204 on cache miss (#73649)

Andrew Clark· Dec 10, 2024, 04:31 PM+1134816bfce64ef
packages/next/src/client/components/segment-cache/cache.ts+19 5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,9 @@
+export const experimental_ppr = true
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
More files changed — see the full commit.

[backport]: properly gate segmentCache branch in base-server

Zack Tanner· May 22, 2025, 08:17 PM+1512a15b974ed7
packages/next/src/server/base-server.ts+13 10
@@ -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
@@ -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
@@ -3,7 +3,6 @@ import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html>
- <Head />
<Head />
<body>
<Main />

References