diff --git a/packages/next/errors.json b/packages/next/errors.json index 850446c6fe5a62..0b0cd22179a7a7 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -857,5 +857,8 @@ "856": "`lockfileTryAcquireSync` is not supported by the wasm bindings.", "857": "`lockfileUnlock` is not supported by the wasm bindings.", "858": "`lockfileUnlockSync` is not supported by the wasm bindings.", - "859": "An IO error occurred while attempting to create and acquire the lockfile" + "859": "An IO error occurred while attempting to create and acquire the lockfile", + "860": "Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., \"5mb\")", + "861": "Client Max Body Size must be larger than 0 bytes", + "862": "Request body exceeded %s" } diff --git a/packages/next/src/server/body-streams.ts b/packages/next/src/server/body-streams.ts index 885dbd0aff4b8f..d005106ec43446 100644 --- a/packages/next/src/server/body-streams.ts +++ b/packages/next/src/server/body-streams.ts @@ -1,6 +1,9 @@ import type { IncomingMessage } from 'http' import type { Readable } from 'stream' import { PassThrough } from 'stream' +import bytes from 'next/dist/compiled/bytes' + +const DEFAULT_BODY_CLONE_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB export function requestToBodyStream( context: { ReadableStream: typeof ReadableStream }, @@ -38,7 +41,8 @@ export interface CloneableBody { } export function getCloneableBody( - readable: T + readable: T, + sizeLimit?: number ): CloneableBody { let buffered: Readable | null = null @@ -76,13 +80,34 @@ export function getCloneableBody( const input = buffered ?? readable const p1 = new PassThrough() const p2 = new PassThrough() + + let bytesRead = 0 + const bodySizeLimit = sizeLimit ?? DEFAULT_BODY_CLONE_SIZE_LIMIT + let limitExceeded = false + input.on('data', (chunk) => { + if (limitExceeded) return + + bytesRead += chunk.length + + if (bytesRead > bodySizeLimit) { + limitExceeded = true + const error = new Error( + `Request body exceeded ${bytes.format(bodySizeLimit)}` + ) + p1.destroy(error) + p2.destroy(error) + return + } + p1.push(chunk) p2.push(chunk) }) input.on('end', () => { - p1.push(null) - p2.push(null) + if (!limitExceeded) { + p1.push(null) + p2.push(null) + } }) buffered = p2 return p1 diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index ead914161989f7..da9eafaea0d873 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -797,6 +797,12 @@ export interface ExperimentalConfig { */ isolatedDevBuild?: boolean + /** + * Body size limit for request bodies with middleware configured. + * Defaults to 10MB. Can be specified as a number (bytes) or string (e.g. '5mb'). + */ + middlewareClientMaxBodySize?: SizeLimit + /** * Enable the Model Context Protocol (MCP) server for AI-assisted development. * When enabled, Next.js will expose an MCP server at `/_next/mcp` that provides diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index e7f92c2d57423b..b5ef5cd02d3ac4 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -701,6 +701,32 @@ function assignDefaultsAndValidate( } } + // Normalize & validate experimental.middlewareClientMaxBodySize + if (typeof result.experimental?.middlewareClientMaxBodySize !== 'undefined') { + const middlewareClientMaxBodySize = + result.experimental.middlewareClientMaxBodySize + let normalizedValue: number + + if (typeof middlewareClientMaxBodySize === 'string') { + const bytes = + require('next/dist/compiled/bytes') as typeof import('next/dist/compiled/bytes') + normalizedValue = bytes.parse(middlewareClientMaxBodySize) + } else if (typeof middlewareClientMaxBodySize === 'number') { + normalizedValue = middlewareClientMaxBodySize + } else { + throw new Error( + 'Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., "5mb")' + ) + } + + if (isNaN(normalizedValue) || normalizedValue < 1) { + throw new Error('Client Max Body Size must be larger than 0 bytes') + } + + // Store the normalized value as a number + result.experimental.middlewareClientMaxBodySize = normalizedValue + } + warnOptionHasBeenMovedOutOfExperimental( result, 'transpilePackages', diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index 57a541de966cc6..0f42b68e7330e6 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -175,7 +175,10 @@ export function getResolveRoutes( addRequestMeta(req, 'initProtocol', protocol) if (!isUpgradeReq) { - addRequestMeta(req, 'clonableBody', getCloneableBody(req)) + const bodySizeLimit = config.experimental.middlewareClientMaxBodySize as + | number + | undefined + addRequestMeta(req, 'clonableBody', getCloneableBody(req, bodySizeLimit)) } const maybeAddTrailingSlash = (pathname: string) => { diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 7e5f627ffb62ca..0d5b64e19f1ac4 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -1950,7 +1950,13 @@ export default class NextNodeServer extends BaseServer< addRequestMeta(req, 'initProtocol', protocol) if (!isUpgradeReq) { - addRequestMeta(req, 'clonableBody', getCloneableBody(req.originalRequest)) + const bodySizeLimit = this.nextConfig.experimental + ?.middlewareClientMaxBodySize as number | undefined + addRequestMeta( + req, + 'clonableBody', + getCloneableBody(req.originalRequest, bodySizeLimit) + ) } } diff --git a/test/e2e/client-max-body-size/app/api/echo/route.ts b/test/e2e/client-max-body-size/app/api/echo/route.ts new file mode 100644 index 00000000000000..7752aa7d12c13f --- /dev/null +++ b/test/e2e/client-max-body-size/app/api/echo/route.ts @@ -0,0 +1,5 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function POST(request: NextRequest) { + return new NextResponse('Hello World', { status: 200 }) +} diff --git a/test/e2e/client-max-body-size/index.test.ts b/test/e2e/client-max-body-size/index.test.ts new file mode 100644 index 00000000000000..dd4326ecf8fb3e --- /dev/null +++ b/test/e2e/client-max-body-size/index.test.ts @@ -0,0 +1,223 @@ +import { nextTestSetup } from 'e2e-utils' +import { fetchViaHTTP } from 'next-test-utils' + +describe('client-max-body-size', () => { + describe('default 10MB limit', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + // Deployed environment has it's own configured limits. + skipDeployment: true, + }) + + if (skipped) return + + it('should reject request body over 10MB by default', async () => { + const bodySize = 11 * 1024 * 1024 // 11MB + const body = 'x'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(400) + expect(next.cliOutput).toContain('Request body exceeded 10MB') + }) + + it('should accept request body at exactly 10MB', async () => { + const bodySize = 10 * 1024 * 1024 // 10MB + const body = 'y'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(200) + const responseBody = await res.text() + expect(responseBody).toBe('Hello World') + }) + + it('should accept request body under 10MB', async () => { + const bodySize = 5 * 1024 * 1024 // 5MB + const body = 'z'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(200) + const responseBody = await res.text() + expect(responseBody).toBe('Hello World') + }) + }) + + describe('custom limit with string format', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + nextConfig: { + experimental: { + middlewareClientMaxBodySize: '5mb', + }, + }, + }) + + if (skipped) return + + it('should reject request body over custom 5MB limit', async () => { + const bodySize = 6 * 1024 * 1024 // 6MB + const body = 'a'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(400) + expect(next.cliOutput).toContain('Request body exceeded 5MB') + }) + + it('should accept request body under custom 5MB limit', async () => { + const bodySize = 4 * 1024 * 1024 // 4MB + const body = 'b'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(200) + const responseBody = await res.text() + expect(responseBody).toBe('Hello World') + }) + }) + + describe('custom limit with number format', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + nextConfig: { + experimental: { + middlewareClientMaxBodySize: 2 * 1024 * 1024, // 2MB in bytes + }, + }, + }) + + if (skipped) return + + it('should reject request body over custom 2MB limit', async () => { + const bodySize = 3 * 1024 * 1024 // 3MB + const body = 'c'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(400) + expect(next.cliOutput).toContain('Request body exceeded 2MB') + }) + + it('should accept request body under custom 2MB limit', async () => { + const bodySize = 1 * 1024 * 1024 // 1MB + const body = 'd'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(200) + const responseBody = await res.text() + expect(responseBody).toBe('Hello World') + }) + }) + + describe('large custom limit', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + nextConfig: { + experimental: { + middlewareClientMaxBodySize: '50mb', + }, + }, + }) + + if (skipped) return + + it('should accept request body up to 50MB with custom limit', async () => { + const bodySize = 20 * 1024 * 1024 // 20MB + const body = 'e'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(200) + const responseBody = await res.text() + expect(responseBody).toBe('Hello World') + }) + + it('should reject request body over custom 50MB limit', async () => { + const bodySize = 51 * 1024 * 1024 // 51MB + const body = 'f'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/echo', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(400) + expect(next.cliOutput).toContain('Request body exceeded 50MB') + }) + }) +}) diff --git a/test/e2e/client-max-body-size/middleware.ts b/test/e2e/client-max-body-size/middleware.ts new file mode 100644 index 00000000000000..decb3774533d29 --- /dev/null +++ b/test/e2e/client-max-body-size/middleware.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function middleware(request: NextRequest) { + return NextResponse.next() +} + +export const config = { + matcher: '/api/:path*', +}