Next.js: Unbounded postponed resume buffering can lead to DoS
Description
Next.js is a React framework for building full-stack web applications. Starting in version 16.0.1 and prior to version 16.1.7, a request containing the next-resume: 1 header (corresponding with a PPR resume request) would buffer request bodies without consistently enforcing maxPostponedStateSize in certain setups. The previous mitigation protected minimal-mode deployments, but equivalent non-minimal deployments remained vulnerable to the same unbounded postponed resume-body buffering behavior. In applications using the App Router with Partial Prerendering capability enabled (via experimental.ppr or cacheComponents), an attacker could send oversized next-resume POST payloads that were buffered without consistent size enforcement in non-minimal deployments, causing excessive memory usage and potential denial of service. This is fixed in version 16.1.7 by enforcing size limits across all postponed-body buffering paths and erroring when limits are exceeded. If upgrading is not immediately possible, block requests containing the next-resume header, as this is never valid to be sent from an untrusted client.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
nextnpm | >= 16.0.1, < 16.1.7 | 16.1.7 |
Affected products
1Patches
1c885d4825f80ensure maxPostponedStateSize is always respected (#90060)
7 files changed · +148 −68
packages/next/src/build/templates/app-page.ts+28 −39 modified@@ -65,10 +65,12 @@ import type { CacheControl } from '../../server/lib/cache-control' import { ENCODED_TAGS } from '../../server/stream-utils/encoded-tags' import { sendRenderResult } from '../../server/send-payload' import { NoFallbackError } from '../../shared/lib/no-fallback-error.external' +import { parseMaxPostponedStateSize } from '../../shared/lib/size-limit' import { - DEFAULT_MAX_POSTPONED_STATE_SIZE, - parseMaxPostponedStateSize, -} from '../../shared/lib/size-limit' + getMaxPostponedStateSize, + getPostponedStateExceededErrorMessage, + readBodyWithSizeLimit, +} from '../../server/lib/postponed-request-body' // These are injected by the loader afterwards. @@ -243,23 +245,13 @@ export async function handler( typeof resumeStateLengthHeader === 'string' ) { const stateLength = parseInt(resumeStateLengthHeader, 10) - const maxPostponedStateSize = - nextConfig.experimental.maxPostponedStateSize ?? - DEFAULT_MAX_POSTPONED_STATE_SIZE - const maxPostponedStateSizeBytes = parseMaxPostponedStateSize( - nextConfig.experimental.maxPostponedStateSize - ) + const { maxPostponedStateSize, maxPostponedStateSizeBytes } = + getMaxPostponedStateSize(nextConfig.experimental.maxPostponedStateSize) if (!isNaN(stateLength) && stateLength > 0) { - if ( - maxPostponedStateSizeBytes === undefined || - stateLength > maxPostponedStateSizeBytes - ) { + if (stateLength > maxPostponedStateSizeBytes) { res.statusCode = 413 - res.end( - `Postponed state exceeded ${maxPostponedStateSize} limit. ` + - `To configure the limit, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/max-postponed-state-size` - ) + res.end(getPostponedStateExceededErrorMessage(maxPostponedStateSize)) ctx.waitUntil?.(Promise.resolve()) return null } @@ -280,24 +272,16 @@ export async function handler( : 1024 * 1024 // 1 MB const maxTotalBodySize = stateLength + actionBodySizeLimitBytes - // Read the entire body, checking size as we go. - const bodyChunks: Array<Buffer> = [] - let size = 0 - for await (const chunk of req) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - size += buffer.byteLength - if (size > maxTotalBodySize) { - res.statusCode = 413 - res.end( - `Request body exceeded limit. ` + - `To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit` - ) - ctx.waitUntil?.(Promise.resolve()) - return null - } - bodyChunks.push(buffer) + const fullBody = await readBodyWithSizeLimit(req, maxTotalBodySize) + if (fullBody === null) { + res.statusCode = 413 + res.end( + `Request body exceeded limit. ` + + `To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit` + ) + ctx.waitUntil?.(Promise.resolve()) + return null } - const fullBody = Buffer.concat(bodyChunks) if (fullBody.length >= stateLength) { // Extract postponed state from the beginning @@ -323,15 +307,20 @@ export async function handler( req.headers[NEXT_RESUME_HEADER] === '1' && req.method === 'POST' ) { + const { maxPostponedStateSize, maxPostponedStateSizeBytes } = + getMaxPostponedStateSize(nextConfig.experimental.maxPostponedStateSize) + // Decode the postponed state from the request body, it will come as // an array of buffers, so collect them and then concat them to form // the string. - - const body: Array<Buffer> = [] - for await (const chunk of req) { - body.push(chunk) + const body = await readBodyWithSizeLimit(req, maxPostponedStateSizeBytes) + if (body === null) { + res.statusCode = 413 + res.end(getPostponedStateExceededErrorMessage(maxPostponedStateSize)) + ctx.waitUntil?.(Promise.resolve()) + return null } - const postponed = Buffer.concat(body).toString('utf8') + const postponed = body.toString('utf8') addRequestMeta(req, 'postponed', postponed) }
packages/next/src/server/base-server.ts+22 −29 modified@@ -6,10 +6,7 @@ import type { import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' import type { Params } from './request/params' import type { NextConfig, NextConfigRuntime } from './config-shared' -import { - DEFAULT_MAX_POSTPONED_STATE_SIZE, - parseMaxPostponedStateSize, -} from './config-shared' +import { parseMaxPostponedStateSize } from './config-shared' import type { NextParsedUrlQuery, NextUrlWithParsedQuery, @@ -155,6 +152,11 @@ import type { PrerenderedRoute } from '../build/static-paths/types' import { createOpaqueFallbackRouteParams } from './request/fallback-params' import { RouteKind } from './route-kind' import type { ErrorModule } from './load-default-error-components' +import { + getMaxPostponedStateSize, + getPostponedStateExceededErrorMessage, + readBodyWithSizeLimit, +} from './lib/postponed-request-body' export type FindComponentsResult< NextModule extends GenericComponentMod = GenericComponentMod, @@ -1069,37 +1071,28 @@ export default abstract class Server< req.headers[NEXT_RESUME_HEADER] === '1' && req.method === 'POST' ) { - // Get the configured max postponed state size. - const maxPostponedStateSize = - this.nextConfig.experimental.maxPostponedStateSize ?? - DEFAULT_MAX_POSTPONED_STATE_SIZE - const maxPostponedStateSizeBytes = parseMaxPostponedStateSize( - this.nextConfig.experimental.maxPostponedStateSize - ) - if (maxPostponedStateSizeBytes === undefined) { - throw new Error( - 'maxPostponedStateSize must be a valid number (bytes) or filesize format string (e.g., "5mb")' + const { maxPostponedStateSize, maxPostponedStateSizeBytes } = + getMaxPostponedStateSize( + this.nextConfig.experimental.maxPostponedStateSize ) - } // Decode the postponed state from the request body, it will come as // an array of buffers, so collect them and then concat them to form // the string. - const body: Array<Buffer> = [] - let size = 0 - for await (const chunk of req.body) { - size += Buffer.byteLength(chunk) - if (size > maxPostponedStateSizeBytes) { - res.statusCode = 413 - const errorMessage = - `Postponed state exceeded ${maxPostponedStateSize} limit. ` + - `To configure the limit, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/max-postponed-state-size` - res.body(errorMessage).send() - return - } - body.push(chunk) + const body = await readBodyWithSizeLimit( + req.body, + maxPostponedStateSizeBytes + ) + if (body === null) { + res.statusCode = 413 + res + .body( + getPostponedStateExceededErrorMessage(maxPostponedStateSize) + ) + .send() + return } - const postponed = Buffer.concat(body).toString('utf8') + const postponed = body.toString('utf8') addRequestMeta(req, 'postponed', postponed) }
packages/next/src/server/lib/postponed-request-body.ts+61 −0 added@@ -0,0 +1,61 @@ +import { + DEFAULT_MAX_POSTPONED_STATE_SIZE, + parseMaxPostponedStateSize, +} from '../../shared/lib/size-limit' +import type { SizeLimit } from '../../types' + +const INVALID_MAX_POSTPONED_STATE_SIZE_ERROR_MESSAGE = + 'maxPostponedStateSize must be a valid number (bytes) or filesize format string (e.g., "5mb")' + +export type PostponedRequestBodyChunk = Buffer | Uint8Array | string + +export function getMaxPostponedStateSize( + configuredMaxPostponedStateSize: SizeLimit | undefined +): { + maxPostponedStateSize: SizeLimit + maxPostponedStateSizeBytes: number +} { + const maxPostponedStateSize = + configuredMaxPostponedStateSize ?? DEFAULT_MAX_POSTPONED_STATE_SIZE + const maxPostponedStateSizeBytes = parseMaxPostponedStateSize( + configuredMaxPostponedStateSize + ) + + if (maxPostponedStateSizeBytes === undefined) { + throw new Error(INVALID_MAX_POSTPONED_STATE_SIZE_ERROR_MESSAGE) + } + + return { maxPostponedStateSize, maxPostponedStateSizeBytes } +} + +export function getPostponedStateExceededErrorMessage( + maxPostponedStateSize: SizeLimit +): string { + return ( + `Postponed state exceeded ${maxPostponedStateSize} limit. ` + + `To configure the limit, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/max-postponed-state-size` + ) +} + +function toBuffer(chunk: PostponedRequestBodyChunk): Buffer { + return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) +} + +export async function readBodyWithSizeLimit( + body: AsyncIterable<PostponedRequestBodyChunk>, + maxBodySizeBytes: number +): Promise<Buffer | null> { + const chunks: Array<Buffer> = [] + let size = 0 + + for await (const chunk of body) { + const buffer = toBuffer(chunk) + size += buffer.byteLength + if (size > maxBodySizeBytes) { + return null + } + chunks.push(buffer) + } + + return Buffer.concat(chunks) +}
test/production/app-dir/max-postponed-state-size/app/layout.tsx+7 −0 added@@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <html> + <body>{children}</body> + </html> + ) +}
test/production/app-dir/max-postponed-state-size/app/page.tsx+3 −0 added@@ -0,0 +1,3 @@ +export default function Page() { + return <p>Hello World</p> +}
test/production/app-dir/max-postponed-state-size/max-postponed-state-size.test.ts+21 −0 added@@ -0,0 +1,21 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('app-dir - max postponed state size', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should return 413 when next-resume request exceeds max postponed state size', async () => { + const res = await next.fetch('/', { + method: 'POST', + headers: { + 'next-action': 'abc123', + 'next-resume': '1', + }, + body: Buffer.alloc(1025, 'x'), + }) + + expect(res.status).toBe(413) + expect(await res.text()).toContain('Postponed state exceeded 1 KB limit.') + }) +})
test/production/app-dir/max-postponed-state-size/next.config.js+6 −0 added@@ -0,0 +1,6 @@ +module.exports = { + cacheComponents: true, + experimental: { + maxPostponedStateSize: '1 KB', + }, +}
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
5- github.com/advisories/GHSA-h27x-g6w4-24gqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27979ghsaADVISORY
- github.com/vercel/next.js/commit/c885d4825f800dd1e49ead37274dcd08cdd6f3f1ghsax_refsource_MISCWEB
- github.com/vercel/next.js/releases/tag/v16.1.7ghsax_refsource_MISCWEB
- github.com/vercel/next.js/security/advisories/GHSA-h27x-g6w4-24gqghsax_refsource_CONFIRMWEB
News mentions
1- The Good, the Bad and the Ugly in Cybersecurity – Week 19SentinelOne Labs · May 8, 2026