Skip to content

Commit 714a702

Browse files
ztannerijjk
andauthored
[middleware]: add upper bound to cloneBodyStream (#84539)
<!-- Thanks for opening a PR! Your contribution is much appreciated. To make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below. Choose the right checklist for the change(s) that you're making: ## For Contributors ### Improving Documentation - Run `pnpm prettier-fix` to fix formatting issues before opening the PR. - Read the Docs Contribution Guide to ensure your contribution follows the docs guidelines: https://nextjs.org/docs/community/contribution-guide ### Fixing a bug - Related issues linked using `fixes #number` - Tests added. See: https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs - Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md ### Adding a feature - Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. (A discussion must be opened, see https://github.com/vercel/next.js/discussions/new?category=ideas) - Related issues/discussions are linked using `fixes #number` - e2e tests added (https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) - Documentation added - Telemetry added. In case of a feature if it's used or not. - Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md ## For Maintainers - Minimal description (aim for explaining to someone not on the team to understand the PR) - When linking to a Slack thread, you might want to share details of the conclusion - Link both the Linear (Fixes NEXT-xxx) and the GitHub issues - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Closes NEXT- Fixes # --> --------- Co-authored-by: JJ Kasper <[email protected]>
1 parent a8f55b9 commit 714a702

File tree

9 files changed

+312
-6
lines changed

9 files changed

+312
-6
lines changed

packages/next/errors.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,5 +857,8 @@
857857
"856": "`lockfileTryAcquireSync` is not supported by the wasm bindings.",
858858
"857": "`lockfileUnlock` is not supported by the wasm bindings.",
859859
"858": "`lockfileUnlockSync` is not supported by the wasm bindings.",
860-
"859": "An IO error occurred while attempting to create and acquire the lockfile"
860+
"859": "An IO error occurred while attempting to create and acquire the lockfile",
861+
"860": "Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., \"5mb\")",
862+
"861": "Client Max Body Size must be larger than 0 bytes",
863+
"862": "Request body exceeded %s"
861864
}

packages/next/src/server/body-streams.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { IncomingMessage } from 'http'
22
import type { Readable } from 'stream'
33
import { PassThrough } from 'stream'
4+
import bytes from 'next/dist/compiled/bytes'
5+
6+
const DEFAULT_BODY_CLONE_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB
47

58
export function requestToBodyStream(
69
context: { ReadableStream: typeof ReadableStream },
@@ -38,7 +41,8 @@ export interface CloneableBody {
3841
}
3942

4043
export function getCloneableBody<T extends IncomingMessage>(
41-
readable: T
44+
readable: T,
45+
sizeLimit?: number
4246
): CloneableBody {
4347
let buffered: Readable | null = null
4448

@@ -76,13 +80,34 @@ export function getCloneableBody<T extends IncomingMessage>(
7680
const input = buffered ?? readable
7781
const p1 = new PassThrough()
7882
const p2 = new PassThrough()
83+
84+
let bytesRead = 0
85+
const bodySizeLimit = sizeLimit ?? DEFAULT_BODY_CLONE_SIZE_LIMIT
86+
let limitExceeded = false
87+
7988
input.on('data', (chunk) => {
89+
if (limitExceeded) return
90+
91+
bytesRead += chunk.length
92+
93+
if (bytesRead > bodySizeLimit) {
94+
limitExceeded = true
95+
const error = new Error(
96+
`Request body exceeded ${bytes.format(bodySizeLimit)}`
97+
)
98+
p1.destroy(error)
99+
p2.destroy(error)
100+
return
101+
}
102+
80103
p1.push(chunk)
81104
p2.push(chunk)
82105
})
83106
input.on('end', () => {
84-
p1.push(null)
85-
p2.push(null)
107+
if (!limitExceeded) {
108+
p1.push(null)
109+
p2.push(null)
110+
}
86111
})
87112
buffered = p2
88113
return p1

packages/next/src/server/config-shared.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,12 @@ export interface ExperimentalConfig {
797797
*/
798798
isolatedDevBuild?: boolean
799799

800+
/**
801+
* Body size limit for request bodies with middleware configured.
802+
* Defaults to 10MB. Can be specified as a number (bytes) or string (e.g. '5mb').
803+
*/
804+
middlewareClientMaxBodySize?: SizeLimit
805+
800806
/**
801807
* Enable the Model Context Protocol (MCP) server for AI-assisted development.
802808
* When enabled, Next.js will expose an MCP server at `/_next/mcp` that provides

packages/next/src/server/config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,32 @@ function assignDefaultsAndValidate(
701701
}
702702
}
703703

704+
// Normalize & validate experimental.middlewareClientMaxBodySize
705+
if (typeof result.experimental?.middlewareClientMaxBodySize !== 'undefined') {
706+
const middlewareClientMaxBodySize =
707+
result.experimental.middlewareClientMaxBodySize
708+
let normalizedValue: number
709+
710+
if (typeof middlewareClientMaxBodySize === 'string') {
711+
const bytes =
712+
require('next/dist/compiled/bytes') as typeof import('next/dist/compiled/bytes')
713+
normalizedValue = bytes.parse(middlewareClientMaxBodySize)
714+
} else if (typeof middlewareClientMaxBodySize === 'number') {
715+
normalizedValue = middlewareClientMaxBodySize
716+
} else {
717+
throw new Error(
718+
'Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., "5mb")'
719+
)
720+
}
721+
722+
if (isNaN(normalizedValue) || normalizedValue < 1) {
723+
throw new Error('Client Max Body Size must be larger than 0 bytes')
724+
}
725+
726+
// Store the normalized value as a number
727+
result.experimental.middlewareClientMaxBodySize = normalizedValue
728+
}
729+
704730
warnOptionHasBeenMovedOutOfExperimental(
705731
result,
706732
'transpilePackages',

packages/next/src/server/lib/router-utils/resolve-routes.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,10 @@ export function getResolveRoutes(
175175
addRequestMeta(req, 'initProtocol', protocol)
176176

177177
if (!isUpgradeReq) {
178-
addRequestMeta(req, 'clonableBody', getCloneableBody(req))
178+
const bodySizeLimit = config.experimental.middlewareClientMaxBodySize as
179+
| number
180+
| undefined
181+
addRequestMeta(req, 'clonableBody', getCloneableBody(req, bodySizeLimit))
179182
}
180183

181184
const maybeAddTrailingSlash = (pathname: string) => {

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1952,7 +1952,13 @@ export default class NextNodeServer extends BaseServer<
19521952
addRequestMeta(req, 'initProtocol', protocol)
19531953

19541954
if (!isUpgradeReq) {
1955-
addRequestMeta(req, 'clonableBody', getCloneableBody(req.originalRequest))
1955+
const bodySizeLimit = this.nextConfig.experimental
1956+
?.middlewareClientMaxBodySize as number | undefined
1957+
addRequestMeta(
1958+
req,
1959+
'clonableBody',
1960+
getCloneableBody(req.originalRequest, bodySizeLimit)
1961+
)
19561962
}
19571963
}
19581964

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
export async function POST(request: NextRequest) {
4+
return new NextResponse('Hello World', { status: 200 })
5+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { fetchViaHTTP } from 'next-test-utils'
3+
4+
describe('client-max-body-size', () => {
5+
describe('default 10MB limit', () => {
6+
const { next, skipped } = nextTestSetup({
7+
files: __dirname,
8+
// Deployed environment has it's own configured limits.
9+
skipDeployment: true,
10+
})
11+
12+
if (skipped) return
13+
14+
it('should reject request body over 10MB by default', async () => {
15+
const bodySize = 11 * 1024 * 1024 // 11MB
16+
const body = 'x'.repeat(bodySize)
17+
18+
const res = await fetchViaHTTP(
19+
next.url,
20+
'/api/echo',
21+
{},
22+
{
23+
body,
24+
method: 'POST',
25+
}
26+
)
27+
28+
expect(res.status).toBe(400)
29+
expect(next.cliOutput).toContain('Request body exceeded 10MB')
30+
})
31+
32+
it('should accept request body at exactly 10MB', async () => {
33+
const bodySize = 10 * 1024 * 1024 // 10MB
34+
const body = 'y'.repeat(bodySize)
35+
36+
const res = await fetchViaHTTP(
37+
next.url,
38+
'/api/echo',
39+
{},
40+
{
41+
body,
42+
method: 'POST',
43+
}
44+
)
45+
46+
expect(res.status).toBe(200)
47+
const responseBody = await res.text()
48+
expect(responseBody).toBe('Hello World')
49+
})
50+
51+
it('should accept request body under 10MB', async () => {
52+
const bodySize = 5 * 1024 * 1024 // 5MB
53+
const body = 'z'.repeat(bodySize)
54+
55+
const res = await fetchViaHTTP(
56+
next.url,
57+
'/api/echo',
58+
{},
59+
{
60+
body,
61+
method: 'POST',
62+
}
63+
)
64+
65+
expect(res.status).toBe(200)
66+
const responseBody = await res.text()
67+
expect(responseBody).toBe('Hello World')
68+
})
69+
})
70+
71+
describe('custom limit with string format', () => {
72+
const { next, skipped } = nextTestSetup({
73+
files: __dirname,
74+
skipDeployment: true,
75+
nextConfig: {
76+
experimental: {
77+
middlewareClientMaxBodySize: '5mb',
78+
},
79+
},
80+
})
81+
82+
if (skipped) return
83+
84+
it('should reject request body over custom 5MB limit', async () => {
85+
const bodySize = 6 * 1024 * 1024 // 6MB
86+
const body = 'a'.repeat(bodySize)
87+
88+
const res = await fetchViaHTTP(
89+
next.url,
90+
'/api/echo',
91+
{},
92+
{
93+
body,
94+
method: 'POST',
95+
}
96+
)
97+
98+
expect(res.status).toBe(400)
99+
expect(next.cliOutput).toContain('Request body exceeded 5MB')
100+
})
101+
102+
it('should accept request body under custom 5MB limit', async () => {
103+
const bodySize = 4 * 1024 * 1024 // 4MB
104+
const body = 'b'.repeat(bodySize)
105+
106+
const res = await fetchViaHTTP(
107+
next.url,
108+
'/api/echo',
109+
{},
110+
{
111+
body,
112+
method: 'POST',
113+
}
114+
)
115+
116+
expect(res.status).toBe(200)
117+
const responseBody = await res.text()
118+
expect(responseBody).toBe('Hello World')
119+
})
120+
})
121+
122+
describe('custom limit with number format', () => {
123+
const { next, skipped } = nextTestSetup({
124+
files: __dirname,
125+
skipDeployment: true,
126+
nextConfig: {
127+
experimental: {
128+
middlewareClientMaxBodySize: 2 * 1024 * 1024, // 2MB in bytes
129+
},
130+
},
131+
})
132+
133+
if (skipped) return
134+
135+
it('should reject request body over custom 2MB limit', async () => {
136+
const bodySize = 3 * 1024 * 1024 // 3MB
137+
const body = 'c'.repeat(bodySize)
138+
139+
const res = await fetchViaHTTP(
140+
next.url,
141+
'/api/echo',
142+
{},
143+
{
144+
body,
145+
method: 'POST',
146+
}
147+
)
148+
149+
expect(res.status).toBe(400)
150+
expect(next.cliOutput).toContain('Request body exceeded 2MB')
151+
})
152+
153+
it('should accept request body under custom 2MB limit', async () => {
154+
const bodySize = 1 * 1024 * 1024 // 1MB
155+
const body = 'd'.repeat(bodySize)
156+
157+
const res = await fetchViaHTTP(
158+
next.url,
159+
'/api/echo',
160+
{},
161+
{
162+
body,
163+
method: 'POST',
164+
}
165+
)
166+
167+
expect(res.status).toBe(200)
168+
const responseBody = await res.text()
169+
expect(responseBody).toBe('Hello World')
170+
})
171+
})
172+
173+
describe('large custom limit', () => {
174+
const { next, skipped } = nextTestSetup({
175+
files: __dirname,
176+
skipDeployment: true,
177+
nextConfig: {
178+
experimental: {
179+
middlewareClientMaxBodySize: '50mb',
180+
},
181+
},
182+
})
183+
184+
if (skipped) return
185+
186+
it('should accept request body up to 50MB with custom limit', async () => {
187+
const bodySize = 20 * 1024 * 1024 // 20MB
188+
const body = 'e'.repeat(bodySize)
189+
190+
const res = await fetchViaHTTP(
191+
next.url,
192+
'/api/echo',
193+
{},
194+
{
195+
body,
196+
method: 'POST',
197+
}
198+
)
199+
200+
expect(res.status).toBe(200)
201+
const responseBody = await res.text()
202+
expect(responseBody).toBe('Hello World')
203+
})
204+
205+
it('should reject request body over custom 50MB limit', async () => {
206+
const bodySize = 51 * 1024 * 1024 // 51MB
207+
const body = 'f'.repeat(bodySize)
208+
209+
const res = await fetchViaHTTP(
210+
next.url,
211+
'/api/echo',
212+
{},
213+
{
214+
body,
215+
method: 'POST',
216+
}
217+
)
218+
219+
expect(res.status).toBe(400)
220+
expect(next.cliOutput).toContain('Request body exceeded 50MB')
221+
})
222+
})
223+
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
export async function middleware(request: NextRequest) {
4+
return NextResponse.next()
5+
}
6+
7+
export const config = {
8+
matcher: '/api/:path*',
9+
}

0 commit comments

Comments
 (0)