Security context
MediumGHSA-h27x-g6w4-24gq CVE-2026-27979CWE-770Published Mar 17, 2026

Next.js: Unbounded postponed resume buffering can lead to DoS

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

16.0.1 → fixed in 16.1.7

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)

Zack Tanner· Feb 18, 2026, 04:53 PM+14868c885d4825f
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 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
@@ -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