Skip to content

Commit 02dd242

Browse files
committed
fix(v2): clamp explicit body caps to the proxy ceiling too
The previous commit clamped the default JSON body cap but left explicit per-route overrides alone, so a route declaring a larger `maxBodyBytes` still fell into the truncation it was meant to report: the four inline workspace-file routes at 70 MB and the deployed-chat route at 220 MB. Next attaches `proxyClientMaxBodySize` to every request and clones the body unconditionally for any non-GET method on a matched path, pushing EOF at ten mebibytes with only a warning, so the handler reads a truncated prefix. Those routes therefore already fail above that size — as a malformed-JSON 400. Clamping the effective limit inside the two body readers makes the same request fail as payload-too-large, quoting the limit actually in force. One existing test asserted the unreachable case, allowing a sixty-mebibyte base64 body; it now asserts what the proxy will forward intact. The inline-file path still advertises fifty mebibytes and cannot exceed the proxy ceiling until that ceiling is raised, which changes buffering for every route and belongs in its own change.
1 parent 4891dc6 commit 02dd242

3 files changed

Lines changed: 109 additions & 11 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,23 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => {
135135
})
136136
})
137137

138-
it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => {
138+
it('allows a base64 JSON body up to what the proxy forwards intact', async () => {
139139
const response = await PUT(
140-
createRequest({ content: 'TQ==', encoding: 'base64' }, 60 * 1024 * 1024),
140+
createRequest({ content: 'TQ==', encoding: 'base64' }, 10 * 1024 * 1024),
141141
routeContext
142142
)
143143

144144
expect(response.status).toBe(200)
145145
expect(mocks.updateContent).toHaveBeenCalled()
146146
})
147147

148-
it('rejects a JSON body above the inline-content cap after admission', async () => {
149-
const response = await PUT(createRequest({ content: '' }, 70 * 1024 * 1024 + 1), routeContext)
148+
/**
149+
* The route declares a 70 MB inline cap, but Next's proxy truncates a client
150+
* body past 10 MiB, so the parser clamps to that ceiling and answers 413
151+
* rather than letting a truncated prefix surface as malformed JSON.
152+
*/
153+
it('rejects a JSON body above the proxy ceiling after admission', async () => {
154+
const response = await PUT(createRequest({ content: '' }, 10 * 1024 * 1024 + 1), routeContext)
150155

151156
expect(response.status).toBe(413)
152157
expect(mocks.admit).toHaveBeenCalled()

apps/sim/lib/api/server/validation.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,24 @@
33
*/
44
import { NextRequest } from 'next/server'
55
import { describe, expect, it } from 'vitest'
6-
import { DEFAULT_MAX_JSON_BODY_BYTES, parseJsonBody } from '@/lib/api/server/validation'
6+
import {
7+
DEFAULT_MAX_JSON_BODY_BYTES,
8+
parseJsonBody,
9+
parseOptionalJsonBody,
10+
} from '@/lib/api/server/validation'
711

812
/**
913
* Next.js truncates a proxied client body past `experimental.proxyClientMaxBodySize`
1014
* without signalling it, so this is the largest body a handler can actually receive.
1115
*/
1216
const PROXY_CLIENT_MAX_BODY_BYTES = 10 * 1024 * 1024
1317

18+
/** Mirrors `MAX_WORKSPACE_FILE_INLINE_BODY_BYTES` — an explicit override above the ceiling. */
19+
const INLINE_FILE_BODY_BYTES = 70 * 1024 * 1024
20+
21+
/** Mirrors the knowledge-search override — below the ceiling, so the clamp must not touch it. */
22+
const BELOW_CEILING_BODY_BYTES = 2 * 1024 * 1024
23+
1424
/**
1525
* Declares `content-length` independently of the bytes actually attached, which is
1626
* how the guard sees an oversized request without buffering one in the test.
@@ -60,6 +70,65 @@ describe('parseJsonBody default size boundary', () => {
6070
expect(result.response.status).toBe(413)
6171
})
6272

73+
it('rejects an explicit over-ceiling override the same way, quoting the enforced limit', async () => {
74+
const result = await parseJsonBody(
75+
requestDeclaring(PROXY_CLIENT_MAX_BODY_BYTES + 1, JSON.stringify({ value: 'ok' })),
76+
'response',
77+
INLINE_FILE_BODY_BYTES
78+
)
79+
80+
expect(result.success).toBe(false)
81+
if (result.success) return
82+
expect(result.reason).toBe('too_large')
83+
expect(result.response.status).toBe(413)
84+
await expect(result.response.json()).resolves.toEqual({
85+
error: `Request body exceeds the maximum allowed size of ${PROXY_CLIENT_MAX_BODY_BYTES} bytes`,
86+
})
87+
})
88+
89+
it('still accepts a body at the ceiling under an over-ceiling override', async () => {
90+
const result = await parseJsonBody(
91+
requestDeclaring(PROXY_CLIENT_MAX_BODY_BYTES, JSON.stringify({ value: 'ok' })),
92+
'response',
93+
INLINE_FILE_BODY_BYTES
94+
)
95+
96+
expect(result.success).toBe(true)
97+
})
98+
99+
it('leaves an override below the ceiling exactly as declared', async () => {
100+
const atLimit = await parseJsonBody(
101+
requestDeclaring(BELOW_CEILING_BODY_BYTES, JSON.stringify({ value: 'ok' })),
102+
'response',
103+
BELOW_CEILING_BODY_BYTES
104+
)
105+
expect(atLimit.success).toBe(true)
106+
107+
const overLimit = await parseJsonBody(
108+
requestDeclaring(BELOW_CEILING_BODY_BYTES + 1, JSON.stringify({ value: 'ok' })),
109+
'response',
110+
BELOW_CEILING_BODY_BYTES
111+
)
112+
expect(overLimit.success).toBe(false)
113+
if (overLimit.success) return
114+
expect(overLimit.reason).toBe('too_large')
115+
await expect(overLimit.response.json()).resolves.toEqual({
116+
error: `Request body exceeds the maximum allowed size of ${BELOW_CEILING_BODY_BYTES} bytes`,
117+
})
118+
})
119+
120+
it('applies the same clamp to an optional body', async () => {
121+
const result = await parseOptionalJsonBody(
122+
requestDeclaring(PROXY_CLIENT_MAX_BODY_BYTES + 1, JSON.stringify({ value: 'ok' })),
123+
INLINE_FILE_BODY_BYTES
124+
)
125+
126+
expect(result.success).toBe(false)
127+
if (result.success) return
128+
expect(result.reason).toBe('too_large')
129+
expect(result.response.status).toBe(413)
130+
})
131+
63132
it('still reports a genuinely malformed body as malformed', async () => {
64133
const result = await parseJsonBody(requestDeclaring(7, '{"a": '))
65134

apps/sim/lib/api/server/validation.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,27 @@ export const DEFAULT_MAX_JSON_BODY_BYTES = Math.min(
4646
PROXY_CLIENT_MAX_BODY_BYTES
4747
)
4848

49+
/**
50+
* Clamps a per-route body cap to {@link PROXY_CLIENT_MAX_BODY_BYTES}.
51+
*
52+
* A route that raises `maxBodyBytes` above the proxy ceiling cannot actually
53+
* receive a body that large: the proxy truncates the stream, the handler parses
54+
* a prefix, and the caller gets `400 "Request body must be valid JSON"` for a
55+
* request whose only fault was its size. Clamping at the point of use turns that
56+
* into an accurate `413`; nothing that succeeds today changes, because a body
57+
* over the ceiling already fails — just less honestly.
58+
*
59+
* Consequence worth keeping in view: `MAX_WORKSPACE_FILE_INLINE_BODY_BYTES`
60+
* (70 MB) exists so a 50 MiB file can be sent inline as base64, and that ceiling
61+
* stays unreachable until `experimental.proxyClientMaxBodySize` is raised in
62+
* `apps/sim/next.config.ts`. Raising it changes the memory profile of every
63+
* `/api` route, so it is a separate decision — this clamp only makes the limit
64+
* that is actually in force report itself correctly.
65+
*/
66+
function clampToProxyLimit(maxBytes: number): number {
67+
return Math.min(maxBytes, PROXY_CLIENT_MAX_BODY_BYTES)
68+
}
69+
4970
export interface ValidationErrorBody {
5071
error: string
5172
details: z.core.$ZodIssue[]
@@ -76,7 +97,8 @@ export interface ParseRequestOptions {
7697
/**
7798
* Maximum number of bytes to read for the JSON body before rejecting with a
7899
* 413. Defaults to {@link DEFAULT_MAX_JSON_BODY_BYTES}. Raise this only for
79-
* routes that legitimately accept large JSON payloads (e.g. inline file uploads).
100+
* routes that legitimately accept large JSON payloads (e.g. inline file uploads);
101+
* a value above what the proxy forwards is clamped — see {@link clampToProxyLimit}.
80102
*/
81103
maxBodyBytes?: number
82104
/** Treat an absent or whitespace-only body as `undefined` before contract validation. */
@@ -163,16 +185,17 @@ export async function parseJsonBody(
163185
response: NextResponse<{ error: string }>
164186
}
165187
> {
188+
const limit = clampToProxyLimit(maxBytes)
166189
try {
167-
return { success: true, data: await readJsonBodyWithLimit(request, maxBytes) }
190+
return { success: true, data: await readJsonBodyWithLimit(request, limit) }
168191
} catch (error) {
169192
if (invalidJson === 'throw') throw error
170193
if (isPayloadSizeLimitError(error)) {
171194
return {
172195
success: false,
173196
reason: 'too_large',
174197
response: NextResponse.json(
175-
{ error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` },
198+
{ error: `Request body exceeds the maximum allowed size of ${limit} bytes` },
176199
{ status: 413 }
177200
),
178201
}
@@ -203,13 +226,14 @@ export async function parseOptionalJsonBody(
203226
response: NextResponse<{ error: string }>
204227
}
205228
> {
229+
const limit = clampToProxyLimit(maxBytes)
206230
try {
207-
assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL)
231+
assertContentLengthWithinLimit(request.headers, limit, REQUEST_BODY_LABEL)
208232

209233
const stream = request.body
210234
const text = stream
211235
? new TextDecoder().decode(
212-
await readStreamToBufferWithLimit(stream, { maxBytes, label: REQUEST_BODY_LABEL })
236+
await readStreamToBufferWithLimit(stream, { maxBytes: limit, label: REQUEST_BODY_LABEL })
213237
)
214238
: await request.text()
215239

@@ -223,7 +247,7 @@ export async function parseOptionalJsonBody(
223247
success: false,
224248
reason: 'too_large',
225249
response: NextResponse.json(
226-
{ error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` },
250+
{ error: `Request body exceeds the maximum allowed size of ${limit} bytes` },
227251
{ status: 413 }
228252
),
229253
}

0 commit comments

Comments
 (0)