Add maximum size limit for postponed body parsing#88175
Conversation
Tests Passed |
Stats from current PR🔴 2 regressions
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles: **430 kB** → **430 kB**
|
| Canary | PR | Change | |
|---|---|---|---|
| middleware-b..fest.js gzip | 787 B | 791 B | ✓ |
| Total | 787 B | 791 B |
Build Details
Build Manifests
| Canary | PR | Change | |
|---|---|---|---|
| _buildManifest.js gzip | 447 B | 452 B | 🔴 +5 B (+1%) |
| Total | 447 B | 452 B |
📦 Webpack
Client
Main Bundles
| Canary | PR | Change | |
|---|---|---|---|
| 2086.HASH.js gzip | 169 B | N/A | - |
| 2161-HASH.js gzip | 5.4 kB | N/A | - |
| 2747-HASH.js gzip | 4.48 kB | N/A | - |
| 4322-HASH.js gzip | 52.5 kB | N/A | - |
| ec793fe8-HASH.js gzip | 62.3 kB | N/A | - |
| framework-HASH.js gzip | 59.8 kB | 59.8 kB | ✓ |
| main-app-HASH.js gzip | 250 B | 253 B | 🔴 +3 B (+1%) |
| main-HASH.js gzip | 38.4 kB | 38.8 kB | ✓ |
| webpack-HASH.js gzip | 1.68 kB | 1.69 kB | ✓ |
| 1596.HASH.js gzip | N/A | 169 B | - |
| 2658-HASH.js gzip | N/A | 52.2 kB | - |
| 6349-HASH.js gzip | N/A | 4.46 kB | - |
| 7019-HASH.js gzip | N/A | 5.42 kB | - |
| b17a3386-HASH.js gzip | N/A | 62.3 kB | - |
| Total | 225 kB | 225 kB |
Polyfills
| Canary | PR | Change | |
|---|---|---|---|
| polyfills-HASH.js gzip | 39.4 kB | 39.4 kB | ✓ |
| Total | 39.4 kB | 39.4 kB | ✓ |
Pages
| Canary | PR | Change | |
|---|---|---|---|
| _app-HASH.js gzip | 194 B | 193 B | ✓ |
| _error-HASH.js gzip | 182 B | 182 B | ✓ |
| css-HASH.js gzip | 336 B | 335 B | ✓ |
| dynamic-HASH.js gzip | 1.8 kB | 1.8 kB | ✓ |
| edge-ssr-HASH.js gzip | 256 B | 256 B | ✓ |
| head-HASH.js gzip | 352 B | 349 B | ✓ |
| hooks-HASH.js gzip | 385 B | 384 B | ✓ |
| image-HASH.js gzip | 580 B | 580 B | ✓ |
| index-HASH.js gzip | 259 B | 258 B | ✓ |
| link-HASH.js gzip | 2.5 kB | 2.51 kB | ✓ |
| routerDirect..HASH.js gzip | 319 B | 317 B | ✓ |
| script-HASH.js gzip | 385 B | 387 B | ✓ |
| withRouter-HASH.js gzip | 316 B | 315 B | ✓ |
| 1afbb74e6ecf..834.css gzip | 106 B | 106 B | ✓ |
| Total | 7.97 kB | 7.96 kB | ✅ -8 B |
Server
Edge SSR
| Canary | PR | Change | |
|---|---|---|---|
| edge-ssr.js gzip | 124 kB | 124 kB | ✓ |
| page.js gzip | 239 kB | 240 kB | 🔴 +1.85 kB (+1%) |
| Total | 363 kB | 365 kB |
Middleware
| Canary | PR | Change | |
|---|---|---|---|
| middleware-b..fest.js gzip | 653 B | 657 B | ✓ |
| middleware-r..fest.js gzip | 155 B | 156 B | ✓ |
| middleware.js gzip | 32.7 kB | 33 kB | ✓ |
| edge-runtime..pack.js gzip | 846 B | 846 B | ✓ |
| Total | 34.4 kB | 34.6 kB |
Build Details
Build Manifests
| Canary | PR | Change | |
|---|---|---|---|
| _buildManifest.js gzip | 738 B | 738 B | ✓ |
| Total | 738 B | 738 B | ✓ |
Build Cache
| Canary | PR | Change | |
|---|---|---|---|
| 0.pack gzip | 3.62 MB | 3.62 MB | 🔴 +4.62 kB (+0%) |
| index.pack gzip | 100 kB | 101 kB | ✓ |
| index.pack.old gzip | 99.5 kB | 99.7 kB | ✓ |
| Total | 3.82 MB | 3.83 MB |
🔄 Shared (bundler-independent)
Runtimes
| Canary | PR | Change | |
|---|---|---|---|
| app-page-exp...dev.js gzip | 302 kB | 303 kB | ✓ |
| app-page-exp..prod.js gzip | 157 kB | 158 kB | ✓ |
| app-page-tur...dev.js gzip | 302 kB | 302 kB | ✓ |
| app-page-tur..prod.js gzip | 157 kB | 158 kB | ✓ |
| app-page-tur...dev.js gzip | 299 kB | 299 kB | ✓ |
| app-page-tur..prod.js gzip | 155 kB | 156 kB | ✓ |
| app-page.run...dev.js gzip | 299 kB | 299 kB | ✓ |
| app-page.run..prod.js gzip | 155 kB | 156 kB | ✓ |
| app-route-ex...dev.js gzip | 68.7 kB | 68.7 kB | ✓ |
| app-route-ex..prod.js gzip | 47.5 kB | 47.5 kB | ✓ |
| app-route-tu...dev.js gzip | 68.7 kB | 68.7 kB | ✓ |
| app-route-tu..prod.js gzip | 47.5 kB | 47.5 kB | ✓ |
| app-route-tu...dev.js gzip | 68.3 kB | 68.3 kB | ✓ |
| app-route-tu..prod.js gzip | 47.3 kB | 47.3 kB | ✓ |
| app-route.ru...dev.js gzip | 68.3 kB | 68.3 kB | ✓ |
| app-route.ru..prod.js gzip | 47.3 kB | 47.3 kB | ✓ |
| dist_client_...dev.js gzip | 324 B | 324 B | ✓ |
| dist_client_...dev.js gzip | 326 B | 326 B | ✓ |
| dist_client_...dev.js gzip | 318 B | 318 B | ✓ |
| dist_client_...dev.js gzip | 317 B | 317 B | ✓ |
| pages-api-tu...dev.js gzip | 41.1 kB | 41.1 kB | ✓ |
| pages-api-tu..prod.js gzip | 31.2 kB | 31.2 kB | ✓ |
| pages-api.ru...dev.js gzip | 41 kB | 41 kB | ✓ |
| pages-api.ru..prod.js gzip | 31.2 kB | 31.2 kB | ✓ |
| pages-turbo....dev.js gzip | 50.8 kB | 50.8 kB | ✓ |
| pages-turbo...prod.js gzip | 38.2 kB | 38.2 kB | ✓ |
| pages.runtim...dev.js gzip | 50.7 kB | 50.7 kB | ✓ |
| pages.runtim..prod.js gzip | 38.2 kB | 38.2 kB | ✓ |
| server.runti..prod.js gzip | 60 kB | 62.2 kB | 🔴 +2.23 kB (+4%) |
| Total | 2.68 MB | 2.68 MB |
📝 Changed Files (9 files)
Files with changes:
app-page-exp..ntime.dev.jsapp-page-exp..time.prod.jsapp-page-tur..ntime.dev.jsapp-page-tur..time.prod.jsapp-page-tur..ntime.dev.jsapp-page-tur..time.prod.jsapp-page.runtime.dev.jsapp-page.runtime.prod.jsserver.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js
failed to diffapp-page-exp..time.prod.js
Diff too large to display
app-page-tur..ntime.dev.js
failed to diffapp-page-tur..time.prod.js
Diff too large to display
app-page-tur..ntime.dev.js
failed to diffapp-page-tur..time.prod.js
Diff too large to display
app-page.runtime.dev.js
failed to diffapp-page.runtime.prod.js
Diff too large to display
server.runtime.prod.js
Diff too large to display
3007a48 to
86e5d45
Compare
86e5d45 to
a438be1
Compare
|
Notifying the following users due to files changed in this PR based on this repo's notify modifiers: @timneutkens, @ijjk, @shuding, @huozhi: |
There was a problem hiding this comment.
Additional Suggestion:
Missing config-time validation for maxPostponedStateSize - only runtime validation exists, preventing early error detection
View Details
📝 Patch Details
diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts
index 2856ed1930..951a0f1688 100644
--- a/packages/next/src/server/config.ts
+++ b/packages/next/src/server/config.ts
@@ -771,6 +771,29 @@ function assignDefaultsAndValidate(
}
}
+ // Validate & normalize experimental.maxPostponedStateSize
+ if (typeof result.experimental?.maxPostponedStateSize !== 'undefined') {
+ const bytes =
+ require('next/dist/compiled/bytes') as typeof import('next/dist/compiled/bytes')
+ const maxPostponedStateSize = result.experimental.maxPostponedStateSize
+ let value: number | null
+
+ if (typeof maxPostponedStateSize === 'number') {
+ value = maxPostponedStateSize
+ } else {
+ value = bytes.parse(maxPostponedStateSize)
+ }
+
+ if (value === null || isNaN(value) || value < 1) {
+ throw new Error(
+ 'Max Postponed State Size must be a valid number (bytes) or filesize format string (e.g., "5mb"): https://nextjs.org/docs/app/api-reference/config/next-config-js/max-postponed-state-size'
+ )
+ }
+
+ // Store the normalized value as a number
+ result.experimental.maxPostponedStateSize = value
+ }
+
// Throw if both Middleware and Proxy config are set.
if (
userConfig.experimental?.proxyClientMaxBodySize !== undefined &&
Analysis
The issue was valid. Prior to this fix, maxPostponedStateSize only had runtime validation (in base-server.ts, lines 1064-1075) that would trigger an error only when a PPR resume request is made. In contrast, similar config options like bodySizeLimit and proxyClientMaxBodySize had config-time validation at startup, providing clearer error messages earlier in the initialization process.
The fix adds config-time validation for maxPostponedStateSize in packages/next/src/server/config.ts (after line 771), following the exact same pattern as bodySizeLimit:
- Check if the config value is defined
- Import the bytes library
- Handle both number and string inputs
- Validate the parsed value (not null, not NaN, >= 1)
- Throw a clear error if invalid
- Normalize and store the value as a number in the result config
This ensures that invalid maxPostponedStateSize values are caught immediately when the config is loaded, rather than waiting for a PPR resume request at runtime. The runtime validation in base-server.ts remains as a safety check but becomes effectively unreachable for invalid configs.
| const maxDecompressedSize = maxPostponedStateSizeBytes | ||
| ? maxPostponedStateSizeBytes * 5 | ||
| : 500 * 1024 * 1024 |
There was a problem hiding this comment.
Inconsistent size calculation when maxPostponedStateSizeBytes is undefined. The bytes library parses "100 MB" as 100,000,000 bytes (decimal MB), so 5x = 500,000,000 bytes. However, the hardcoded fallback uses 500 * 1024 * 1024 = 524,288,000 bytes (binary MiB). This creates a ~24 MB discrepancy depending on whether the config was explicitly set or uses the default.
const maxDecompressedSize = maxPostponedStateSizeBytes
? maxPostponedStateSizeBytes * 5
: 500_000_000 // Match the parsed "100 MB" * 5| const maxDecompressedSize = maxPostponedStateSizeBytes | |
| ? maxPostponedStateSizeBytes * 5 | |
| : 500 * 1024 * 1024 | |
| const maxDecompressedSize = maxPostponedStateSizeBytes | |
| ? maxPostponedStateSizeBytes * 5 | |
| : 500_000_000 // Match the parsed "100 MB" * 5 |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
…attacks Adds a configurable experimental.maxPostponedStateSize limit (default 10 MB) that enforces a maximum size for PPR postponed state bodies. This prevents potential OOM exploits from unbounded body reads. When the limit is exceeded, the server returns HTTP 413 with a helpful error message directing users to increase the limit in their next.config.js if needed.
… limit dynamic - Update default `maxPostponedStateSize` from 10MB to 100MB - Make decompression limit dynamic (5x the configured limit) instead of hardcoded 50MB - Extract `parseMaxPostponedStateSize` helper to consolidate the default handling - Pass config through render pipeline to resume data cache creation
433d72c to
bfac3d7
Compare
- Add missing maxPostponedStateSizeBytes parameter to createRenderResumeDataCache calls - Fix serverActions.bodySizeLimit validation to use bytes.parse() instead of parseInt()
bfac3d7 to
a07891c
Compare

What?
Adds a configurable
experimental.maxPostponedStateSizelimit for PPR postponed state body parsing to prevent OOM/DoS attacks.Why?
The postponed state body was read entirely without size limits, creating a potential denial-of-service vector through unbounded memory allocation.
How?
Enforces a 100 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.