Next.js: Unbounded postponed resume buffering can lead to DoS
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 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. ## Impact 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. ## Patches Fixed by enforcing size limits across all postponed-body buffering paths and erroring when limits are exceeded. ## Workarounds If upgrade is not immediately possible: - Block requests containing the `next-resume` header, as this is never valid to be sent from an untrusted client.
The fix
ensure maxPostponedStateSize is always respected (#90060)
packages/next/src/build/templates/app-page.ts+28 −39
@@ -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 MBconst 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@+module.exports = {+cacheComponents: true,+experimental: {+maxPostponedStateSize: '1 KB',+},+}
References
- WEBhttps://github.com/vercel/next.js/security/advisories/GHSA-h27x-g6w4-24gq
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2026-27979
- WEBhttps://github.com/vercel/next.js/commit/c885d4825f800dd1e49ead37274dcd08cdd6f3f1
- PACKAGEhttps://github.com/vercel/next.js
- WEBhttps://github.com/vercel/next.js/releases/tag/v16.1.7