From b5a72c9e7e333b01c1b0d3e3a0ae7dac9282f47c Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:26:39 +0200 Subject: [PATCH 1/2] fix: Cloud Function multipart requests bypass the maxUploadSize limit --- spec/CloudCodeMultipart.spec.js | 58 +++++++++++++++++++++++++++++++++ src/Routers/FunctionsRouter.js | 33 ++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/spec/CloudCodeMultipart.spec.js b/spec/CloudCodeMultipart.spec.js index b2f60c0761..943df7b264 100644 --- a/spec/CloudCodeMultipart.spec.js +++ b/spec/CloudCodeMultipart.spec.js @@ -363,4 +363,62 @@ describe('Cloud Code Multipart', () => { expect(result.status).toBe(200); expect(result.data.result.isMaster).toBe(false); }); + + it('should reject multipart request with many empty parts whose wire size exceeds maxUploadSize', async () => { + await reconfigureServer({ maxUploadSize: '1kb' }); + + Parse.Cloud.define('multipartManyEmptyParts', req => { + return { count: Object.keys(req.params).length }; + }); + + const boundary = '----TestBoundaryManyEmptyParts'; + const parts = []; + for (let i = 0; i < 2000; i++) { + parts.push({ name: `f${i}`, value: '' }); + } + const body = buildMultipartBody(boundary, parts); + // The wire body is far larger than maxUploadSize even though every field + // value is empty, so the value/chunk byte counters alone never trip. + expect(body.length).toBeGreaterThan(100 * 1024); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartManyEmptyParts`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE); + }); + + it('should reject multipart request whose Content-Length exceeds maxUploadSize', async () => { + await reconfigureServer({ maxUploadSize: '1kb' }); + + Parse.Cloud.define('multipartContentLength', req => { + return { count: Object.keys(req.params).length }; + }); + + const boundary = '----TestBoundaryContentLength'; + const parts = []; + for (let i = 0; i < 2000; i++) { + parts.push({ name: `f${i}`, value: '' }); + } + const body = buildMultipartBody(boundary, parts); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartContentLength`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(body.length), + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE); + }); }); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 8bcf8a5858..a24bf3fdba 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -201,6 +201,16 @@ export class FunctionsRouter extends PromiseRouter { return Promise.resolve(); } const maxBytes = Utils.parseSizeToBytes(req.config.maxUploadSize); + // Reject early when the declared request size already exceeds the limit. + const contentLength = Number(req.headers['content-length']); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return Promise.reject( + new Parse.Error( + Parse.Error.OBJECT_TOO_LARGE, + 'Multipart request exceeds maximum upload size.' + ) + ); + } return new Promise((resolve, reject) => { const fields = Object.create(null); let totalBytes = 0; @@ -213,11 +223,12 @@ export class FunctionsRouter extends PromiseRouter { new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`) ); } - const safeReject = (err) => { + const safeReject = err => { if (settled) { return; } settled = true; + req.unpipe(busboy); busboy.destroy(); reject(err); }; @@ -280,6 +291,26 @@ export class FunctionsRouter extends PromiseRouter { new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`) ); }); + // Enforce `maxUploadSize` against the raw request bytes (multipart + // boundaries, part headers, field names and part count included), not only + // the parsed field values and file contents. This mirrors how + // `express.json` bounds non-multipart bodies and stops a request composed + // of many empty parts from exceeding the limit on the wire. + let rawBytes = 0; + req.on('data', chunk => { + if (settled) { + return; + } + rawBytes += chunk.length; + if (rawBytes > maxBytes) { + safeReject( + new Parse.Error( + Parse.Error.OBJECT_TOO_LARGE, + 'Multipart request exceeds maximum upload size.' + ) + ); + } + }); req.pipe(busboy); }); } From 3eda7af5c1fd587471c1ee5dc5a726a6014a1892 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:11:18 +0200 Subject: [PATCH 2/2] refactor: Remove redundant settled guard in multipart raw-byte counter --- src/Routers/FunctionsRouter.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index a24bf3fdba..61b3545333 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -298,9 +298,6 @@ export class FunctionsRouter extends PromiseRouter { // of many empty parts from exceeding the limit on the wire. let rawBytes = 0; req.on('data', chunk => { - if (settled) { - return; - } rawBytes += chunk.length; if (rawBytes > maxBytes) { safeReject(