Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
31 changes: 28 additions & 3 deletions packages/next/src/server/body-streams.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down Expand Up @@ -38,7 +41,8 @@ export interface CloneableBody {
}

export function getCloneableBody<T extends IncomingMessage>(
readable: T
readable: T,
sizeLimit?: number
): CloneableBody {
let buffered: Readable | null = null

Expand Down Expand Up @@ -76,13 +80,34 @@ export function getCloneableBody<T extends IncomingMessage>(
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
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/next/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion packages/next/src/server/lib/router-utils/resolve-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
8 changes: 7 additions & 1 deletion packages/next/src/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}

Expand Down
5 changes: 5 additions & 0 deletions test/e2e/client-max-body-size/app/api/echo/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
return new NextResponse('Hello World', { status: 200 })
}
223 changes: 223 additions & 0 deletions test/e2e/client-max-body-size/index.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
9 changes: 9 additions & 0 deletions test/e2e/client-max-body-size/middleware.ts
Original file line number Diff line number Diff line change
@@ -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*',
}
Loading