Skip to content

Commit bb44ffe

Browse files
committed
[middleware]: add upper bound to cloneBodyStream
1 parent 640d7bc commit bb44ffe

File tree

10 files changed

+320
-6
lines changed

10 files changed

+320
-6
lines changed

packages/next/errors.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,5 +852,8 @@
852852
"851": "Pass either `webpack` or `turbopack`, not both.",
853853
"852": "Only custom servers can pass `webpack`, `turbo`, or `turbopack`.",
854854
"853": "Turbopack build failed",
855-
"854": "Expected a %s request header."
855+
"854": "Expected a %s request header.",
856+
"855": "Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., \"5mb\")",
857+
"856": "Client Max Body Size must be larger than 0 bytes",
858+
"857": "Request body exceeded %s"
856859
}

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

Lines changed: 32 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,38 @@ 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+
}
111+
})
112+
input.on('error', (err) => {
113+
p1.destroy(err)
114+
p2.destroy(err)
86115
})
87116
buffered = p2
88117
return p1

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,12 @@ export interface ExperimentalConfig {
790790
*/
791791
isolatedDevBuild?: boolean
792792

793+
/**
794+
* Body size limit for cloning request bodies in middleware.
795+
* Defaults to 10MB. Can be specified as a number (bytes) or string (e.g. '5mb').
796+
*/
797+
clientMaxBodySize?: SizeLimit
798+
793799
/**
794800
* Enable the Model Context Protocol (MCP) server for AI-assisted development.
795801
* When enabled, Next.js will expose an MCP server at `/_next/mcp` that provides

packages/next/src/server/config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,31 @@ function assignDefaultsAndValidate(
672672
}
673673
}
674674

675+
// Normalize & validate experimental.clientMaxBodySize
676+
if (typeof result.experimental?.clientMaxBodySize !== 'undefined') {
677+
const clientMaxBodySize = result.experimental.clientMaxBodySize
678+
let normalizedValue: number
679+
680+
if (typeof clientMaxBodySize === 'string') {
681+
const bytes =
682+
require('next/dist/compiled/bytes') as typeof import('next/dist/compiled/bytes')
683+
normalizedValue = bytes.parse(clientMaxBodySize)
684+
} else if (typeof clientMaxBodySize === 'number') {
685+
normalizedValue = clientMaxBodySize
686+
} else {
687+
throw new Error(
688+
'Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., "5mb")'
689+
)
690+
}
691+
692+
if (isNaN(normalizedValue) || normalizedValue < 1) {
693+
throw new Error('Client Max Body Size must be larger than 0 bytes')
694+
}
695+
696+
// Store the normalized value as a number
697+
result.experimental.clientMaxBodySize = normalizedValue
698+
}
699+
675700
warnOptionHasBeenMovedOutOfExperimental(
676701
result,
677702
'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.clientMaxBodySize 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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1950,7 +1950,14 @@ export default class NextNodeServer extends BaseServer<
19501950
addRequestMeta(req, 'initProtocol', protocol)
19511951

19521952
if (!isUpgradeReq) {
1953-
addRequestMeta(req, 'clonableBody', getCloneableBody(req.originalRequest))
1953+
const bodySizeLimit = this.nextConfig.experimental?.clientMaxBodySize as
1954+
| number
1955+
| undefined
1956+
addRequestMeta(
1957+
req,
1958+
'clonableBody',
1959+
getCloneableBody(req.originalRequest, bodySizeLimit)
1960+
)
19541961
}
19551962
}
19561963

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

test/e2e/middleware-fetches-with-body/index.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ describe('Middleware fetches with body', () => {
3737
res.json({ rawBody, body: req.body })
3838
}
3939
`,
40+
'app/api/test-clone-limit/route.js': `
41+
import { NextResponse } from 'next/server'
42+
43+
export async function POST(request) {
44+
try {
45+
const buffer = await request.arrayBuffer()
46+
return NextResponse.json({
47+
success: true,
48+
bodySize: buffer.byteLength
49+
})
50+
} catch (err) {
51+
return NextResponse.json({
52+
error: err.message
53+
}, { status: 500 })
54+
}
55+
}
56+
`,
4057
'middleware.js': `
4158
import { NextResponse } from 'next/server';
4259

0 commit comments

Comments
 (0)