Next.JS vulnerability can lead to DoS via 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
### 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)
packages/next/src/client/components/segment-cache/cache.ts+19 −5
@@ -500,8 +500,17 @@ async function fetchRouteOnCacheMiss(const nextUrl = key.nextUrltry {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+}
[backport]: properly gate segmentCache branch in base-server
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
- WEBhttps://github.com/vercel/next.js/security/advisories/GHSA-67rr-84xm-4c7r
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2025-49826
- WEBhttps://github.com/vercel/next.js/commit/16bfce64ef2157f2c1dfedcfdb7771bc63103fd2
- WEBhttps://github.com/vercel/next.js/commit/a15b974ed707d63ad4da5b74c1441f5b7b120e93
- PACKAGEhttps://github.com/vercel/next.js
- WEBhttps://github.com/vercel/next.js/releases/tag/v15.1.8
- WEBhttps://vercel.com/changelog/cve-2025-49826