Skip to content

Commit e1d1fc6

Browse files
wyattjohunstubbable
authored andcommitted
Add maximum size limit for postponed body parsing (#88175)
Adds a configurable `experimental.maxPostponedStateSize` limit for PPR postponed state body parsing to prevent OOM/DoS attacks. The postponed state body was read entirely without size limits, creating a potential denial-of-service vector through unbounded memory allocation. Enforces a 10 MB default limit (configurable via next.config.js) with byte counting during body parsing. Returns HTTP 413 when exceeded with a helpful error message directing users to increase the limit if needed. <!-- Closes NEXT- --> <!-- Fixes # -->
1 parent 500ec83 commit e1d1fc6

File tree

17 files changed

+154
-24
lines changed

17 files changed

+154
-24
lines changed

packages/next/errors.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -973,5 +973,7 @@
973973
"972": "Failed to resolve pattern \"%s\": %s",
974974
"973": "Server Actions are not enabled for this application. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
975975
"974": "Failed to find Server Action%s. This request might be from an older or newer deployment.\\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
976-
"975": "Failed to find Server Action. This request might be from an older or newer deployment.\\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action"
976+
"975": "Failed to find Server Action. This request might be from an older or newer deployment.\\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
977+
"976": "Decompressed resume data cache exceeded %s byte limit",
978+
"977": "maxPostponedStateSize must be a valid number (bytes) or filesize format string (e.g., \"5mb\")"
977979
}

packages/next/src/build/templates/app-page.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import type { CacheControl } from '../../server/lib/cache-control'
5656
import { ENCODED_TAGS } from '../../server/stream-utils/encoded-tags'
5757
import { sendRenderResult } from '../../server/send-payload'
5858
import { NoFallbackError } from '../../shared/lib/no-fallback-error.external'
59+
import { parseMaxPostponedStateSize } from '../../shared/lib/size-limit'
5960

6061
// These are injected by the loader afterwards.
6162

@@ -593,6 +594,9 @@ export async function handler(
593594
nextConfig.experimental.clientTraceMetadata || ([] as any),
594595
clientParamParsingOrigins:
595596
nextConfig.experimental.clientParamParsingOrigins,
597+
maxPostponedStateSizeBytes: parseMaxPostponedStateSize(
598+
nextConfig.experimental.maxPostponedStateSize
599+
),
596600
},
597601

598602
waitUntil: ctx.waitUntil,

packages/next/src/build/templates/edge-ssr-app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { interopDefault } from '../../lib/interop-default'
2626
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
2727
import { checkIsOnDemandRevalidate } from '../../server/api-utils'
2828
import { CloseController } from '../../server/web/web-on-close'
29+
import { parseMaxPostponedStateSize } from '../../shared/lib/size-limit'
2930

3031
declare const incrementalCacheHandler: any
3132
// OPTIONAL_IMPORT:incrementalCacheHandler
@@ -159,6 +160,9 @@ async function requestHandler(
159160
nextConfig.experimental.clientTraceMetadata || ([] as any),
160161
clientParamParsingOrigins:
161162
nextConfig.experimental.clientParamParsingOrigins,
163+
maxPostponedStateSizeBytes: parseMaxPostponedStateSize(
164+
nextConfig.experimental.maxPostponedStateSize
165+
),
162166
},
163167

164168
incrementalCache: await pageRouteModule.getIncrementalCache(

packages/next/src/export/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
} from '../shared/lib/constants'
4747
import loadConfig from '../server/config'
4848
import type { ExportPathMap } from '../server/config-shared'
49+
import { parseMaxPostponedStateSize } from '../server/config-shared'
4950
import { eventCliSession } from '../telemetry/events'
5051
import { hasNextSupport } from '../server/ci-info'
5152
import { Telemetry } from '../telemetry/storage'
@@ -396,6 +397,9 @@ async function exportAppImpl(
396397
dynamicOnHover: nextConfig.experimental.dynamicOnHover ?? false,
397398
inlineCss: nextConfig.experimental.inlineCss ?? false,
398399
authInterrupts: !!nextConfig.experimental.authInterrupts,
400+
maxPostponedStateSizeBytes: parseMaxPostponedStateSize(
401+
nextConfig.experimental.maxPostponedStateSize
402+
),
399403
},
400404
reactMaxHeadersLength: nextConfig.reactMaxHeadersLength,
401405
hasReadableErrorStacks:

packages/next/src/export/worker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,10 @@ export async function exportPages(
385385
process.env.NODE_OPTIONS?.includes('--inspect')
386386

387387
const renderResumeDataCache = renderResumeDataCachesByPage[page]
388-
? createRenderResumeDataCache(renderResumeDataCachesByPage[page])
388+
? createRenderResumeDataCache(
389+
renderResumeDataCachesByPage[page],
390+
renderOpts.experimental.maxPostponedStateSizeBytes
391+
)
389392
: undefined
390393

391394
while (attempt < maxAttempts) {

packages/next/src/server/app-render/app-render.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2371,7 +2371,8 @@ export const renderToHTMLOrFlight: AppPageRender = (
23712371

23722372
postponedState = parsePostponedState(
23732373
renderOpts.postponed,
2374-
interpolatedParams
2374+
interpolatedParams,
2375+
renderOpts.experimental.maxPostponedStateSizeBytes
23752376
)
23762377
} else {
23772378
interpolatedParams = interpolateParallelRouteParams(

packages/next/src/server/app-render/postponed-state.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe('getDynamicHTMLPostponedState', () => {
5151
isCacheComponentsEnabled
5252
)
5353

54-
const parsed = parsePostponedState(state, { slug: '123' })
54+
const parsed = parsePostponedState(state, { slug: '123' }, undefined)
5555

5656
expect(parsed).toMatchInlineSnapshot(`
5757
{
@@ -109,7 +109,7 @@ describe('getDynamicHTMLPostponedState', () => {
109109

110110
const value = 'hello'
111111
const params = { slug: value }
112-
const parsed = parsePostponedState(state, params)
112+
const parsed = parsePostponedState(state, params, undefined)
113113
expect(parsed).toEqual({
114114
type: DynamicState.HTML,
115115
data: [1, { [value]: value }],
@@ -137,7 +137,7 @@ describe('parsePostponedState', () => {
137137
const params = {
138138
slug: Math.random().toString(16).slice(3),
139139
}
140-
const parsed = parsePostponedState(state, params)
140+
const parsed = parsePostponedState(state, params, undefined)
141141

142142
// Ensure that it parsed it correctly.
143143
expect(parsed).toEqual({
@@ -153,7 +153,7 @@ describe('parsePostponedState', () => {
153153
it('parses a HTML postponed state without fallback params', () => {
154154
const state = `2:{}null`
155155
const params = {}
156-
const parsed = parsePostponedState(state, params)
156+
const parsed = parsePostponedState(state, params, undefined)
157157

158158
// Ensure that it parsed it correctly.
159159
expect(parsed).toEqual({
@@ -165,7 +165,7 @@ describe('parsePostponedState', () => {
165165

166166
it('parses a data postponed state', () => {
167167
const state = '4:nullnull'
168-
const parsed = parsePostponedState(state, {})
168+
const parsed = parsePostponedState(state, {}, undefined)
169169

170170
// Ensure that it parsed it correctly.
171171
expect(parsed).toEqual({

packages/next/src/server/app-render/postponed-state.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ export async function getDynamicDataPostponedState(
116116

117117
export function parsePostponedState(
118118
state: string,
119-
interpolatedParams: Params
119+
interpolatedParams: Params,
120+
maxPostponedStateSizeBytes: number | undefined
120121
): PostponedState {
121122
try {
122123
const postponedStringLengthMatch = state.match(/^([0-9]*):/)?.[1]
@@ -134,7 +135,10 @@ export function parsePostponedState(
134135
)
135136

136137
const renderResumeDataCache = createRenderResumeDataCache(
137-
state.slice(postponedStringLengthMatch.length + postponedStringLength + 1)
138+
state.slice(
139+
postponedStringLengthMatch.length + postponedStringLength + 1
140+
),
141+
maxPostponedStateSizeBytes
138142
)
139143

140144
try {

packages/next/src/server/app-render/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ export interface RenderOptsPartial {
158158
dynamicOnHover: boolean
159159
inlineCss: boolean
160160
authInterrupts: boolean
161+
162+
/**
163+
* The maximum size (in bytes) of the postponed state body for PPR resume
164+
* requests. Used to calculate decompression limits (5x this value).
165+
*/
166+
maxPostponedStateSizeBytes: number | undefined
161167
}
162168
postponed?: string
163169

packages/next/src/server/base-server.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import type {
66
import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher'
77
import type { Params } from './request/params'
88
import type { NextConfig, NextConfigRuntime } from './config-shared'
9+
import {
10+
DEFAULT_MAX_POSTPONED_STATE_SIZE,
11+
parseMaxPostponedStateSize,
12+
} from './config-shared'
913
import type {
1014
NextParsedUrlQuery,
1115
NextUrlWithParsedQuery,
@@ -558,6 +562,9 @@ export default abstract class Server<
558562
dynamicOnHover: this.nextConfig.experimental.dynamicOnHover ?? false,
559563
inlineCss: this.nextConfig.experimental.inlineCss ?? false,
560564
authInterrupts: !!this.nextConfig.experimental.authInterrupts,
565+
maxPostponedStateSizeBytes: parseMaxPostponedStateSize(
566+
this.nextConfig.experimental.maxPostponedStateSize
567+
),
561568
},
562569
onInstrumentationRequestError:
563570
this.instrumentationOnRequestError.bind(this),
@@ -1049,11 +1056,34 @@ export default abstract class Server<
10491056
req.headers[NEXT_RESUME_HEADER] === '1' &&
10501057
req.method === 'POST'
10511058
) {
1059+
// Get the configured max postponed state size.
1060+
const maxPostponedStateSize =
1061+
this.nextConfig.experimental.maxPostponedStateSize ??
1062+
DEFAULT_MAX_POSTPONED_STATE_SIZE
1063+
const maxPostponedStateSizeBytes = parseMaxPostponedStateSize(
1064+
this.nextConfig.experimental.maxPostponedStateSize
1065+
)
1066+
if (maxPostponedStateSizeBytes === undefined) {
1067+
throw new Error(
1068+
'maxPostponedStateSize must be a valid number (bytes) or filesize format string (e.g., "5mb")'
1069+
)
1070+
}
1071+
10521072
// Decode the postponed state from the request body, it will come as
10531073
// an array of buffers, so collect them and then concat them to form
10541074
// the string.
10551075
const body: Array<Buffer> = []
1076+
let size = 0
10561077
for await (const chunk of req.body) {
1078+
size += Buffer.byteLength(chunk)
1079+
if (size > maxPostponedStateSizeBytes) {
1080+
res.statusCode = 413
1081+
const errorMessage =
1082+
`Postponed state exceeded ${maxPostponedStateSize} limit. ` +
1083+
`To configure the limit, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/max-postponed-state-size`
1084+
res.body(errorMessage).send()
1085+
return
1086+
}
10571087
body.push(chunk)
10581088
}
10591089
const postponed = Buffer.concat(body).toString('utf8')

0 commit comments

Comments
 (0)