Skip to content

Commit a3f36a2

Browse files
authored
feat: Add support for invoking Cloud Function with multipart/form-data protocol (#10395)
1 parent 1c2c6f9 commit a3f36a2

File tree

6 files changed

+496
-4
lines changed

6 files changed

+496
-4
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@apollo/server": "5.5.0",
2424
"@as-integrations/express5": "1.1.2",
25+
"@fastify/busboy": "3.2.0",
2526
"@graphql-tools/merge": "9.1.7",
2627
"@graphql-tools/schema": "10.0.31",
2728
"@graphql-tools/utils": "11.0.0",

spec/CloudCodeMultipart.spec.js

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
'use strict';
2+
const http = require('http');
3+
4+
function postMultipart(url, headers, body) {
5+
return new Promise((resolve, reject) => {
6+
const parsed = new URL(url);
7+
const req = http.request(
8+
{
9+
method: 'POST',
10+
hostname: parsed.hostname,
11+
port: parsed.port,
12+
path: parsed.pathname,
13+
headers,
14+
},
15+
res => {
16+
const chunks = [];
17+
res.on('data', chunk => chunks.push(chunk));
18+
res.on('end', () => {
19+
const raw = Buffer.concat(chunks).toString();
20+
try {
21+
resolve({ status: res.statusCode, data: JSON.parse(raw) });
22+
} catch {
23+
resolve({ status: res.statusCode, data: raw });
24+
}
25+
});
26+
}
27+
);
28+
req.on('error', reject);
29+
req.write(body);
30+
req.end();
31+
});
32+
}
33+
34+
function buildMultipartBody(boundary, parts) {
35+
const segments = [];
36+
for (const part of parts) {
37+
segments.push(`--${boundary}\r\n`);
38+
if (part.filename) {
39+
segments.push(
40+
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n`
41+
);
42+
segments.push(`Content-Type: ${part.contentType || 'application/octet-stream'}\r\n\r\n`);
43+
segments.push(part.data);
44+
} else {
45+
segments.push(`Content-Disposition: form-data; name="${part.name}"\r\n\r\n`);
46+
segments.push(part.value);
47+
}
48+
segments.push('\r\n');
49+
}
50+
segments.push(`--${boundary}--\r\n`);
51+
return Buffer.concat(segments.map(s => (typeof s === 'string' ? Buffer.from(s) : s)));
52+
}
53+
54+
describe('Cloud Code Multipart', () => {
55+
it('should not reject multipart requests at the JSON parser level', async () => {
56+
Parse.Cloud.define('multipartTest', req => {
57+
return { received: true };
58+
});
59+
60+
const boundary = '----TestBoundary123';
61+
const body = buildMultipartBody(boundary, [
62+
{ name: 'key', value: 'value' },
63+
]);
64+
65+
const result = await postMultipart(
66+
`http://localhost:8378/1/functions/multipartTest`,
67+
{
68+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
69+
'X-Parse-Application-Id': 'test',
70+
'X-Parse-REST-API-Key': 'rest',
71+
},
72+
body
73+
);
74+
75+
expect(result.status).not.toBe(400);
76+
});
77+
78+
it('should parse text fields from multipart request', async () => {
79+
Parse.Cloud.define('multipartText', req => {
80+
return { userId: req.params.userId, count: req.params.count };
81+
});
82+
83+
const boundary = '----TestBoundary456';
84+
const body = buildMultipartBody(boundary, [
85+
{ name: 'userId', value: 'abc123' },
86+
{ name: 'count', value: '5' },
87+
]);
88+
89+
const result = await postMultipart(
90+
`http://localhost:8378/1/functions/multipartText`,
91+
{
92+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
93+
'X-Parse-Application-Id': 'test',
94+
'X-Parse-REST-API-Key': 'rest',
95+
},
96+
body
97+
);
98+
99+
expect(result.status).toBe(200);
100+
expect(result.data.result.userId).toBe('abc123');
101+
expect(result.data.result.count).toBe('5');
102+
});
103+
104+
it('should parse file fields from multipart request', async () => {
105+
Parse.Cloud.define('multipartFile', req => {
106+
const file = req.params.avatar;
107+
return {
108+
filename: file.filename,
109+
contentType: file.contentType,
110+
size: file.data.length,
111+
content: file.data.toString('utf8'),
112+
};
113+
});
114+
115+
const boundary = '----TestBoundary789';
116+
const fileContent = Buffer.from('hello world');
117+
const body = buildMultipartBody(boundary, [
118+
{ name: 'avatar', filename: 'photo.txt', contentType: 'text/plain', data: fileContent },
119+
]);
120+
121+
const result = await postMultipart(
122+
`http://localhost:8378/1/functions/multipartFile`,
123+
{
124+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
125+
'X-Parse-Application-Id': 'test',
126+
'X-Parse-REST-API-Key': 'rest',
127+
},
128+
body
129+
);
130+
131+
expect(result.status).toBe(200);
132+
expect(result.data.result.filename).toBe('photo.txt');
133+
expect(result.data.result.contentType).toBe('text/plain');
134+
expect(result.data.result.size).toBe(11);
135+
expect(result.data.result.content).toBe('hello world');
136+
});
137+
138+
it('should parse mixed text and file fields from multipart request', async () => {
139+
Parse.Cloud.define('multipartMixed', req => {
140+
return {
141+
userId: req.params.userId,
142+
hasAvatar: !!req.params.avatar,
143+
avatarFilename: req.params.avatar.filename,
144+
};
145+
});
146+
147+
const boundary = '----TestBoundaryMixed';
148+
const body = buildMultipartBody(boundary, [
149+
{ name: 'userId', value: 'user42' },
150+
{ name: 'avatar', filename: 'img.jpg', contentType: 'image/jpeg', data: Buffer.from([0xff, 0xd8, 0xff]) },
151+
]);
152+
153+
const result = await postMultipart(
154+
`http://localhost:8378/1/functions/multipartMixed`,
155+
{
156+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
157+
'X-Parse-Application-Id': 'test',
158+
'X-Parse-REST-API-Key': 'rest',
159+
},
160+
body
161+
);
162+
163+
expect(result.status).toBe(200);
164+
expect(result.data.result.userId).toBe('user42');
165+
expect(result.data.result.hasAvatar).toBe(true);
166+
expect(result.data.result.avatarFilename).toBe('img.jpg');
167+
});
168+
169+
it('should parse multiple file fields from multipart request', async () => {
170+
Parse.Cloud.define('multipartMultiFile', req => {
171+
return {
172+
file1Name: req.params.doc1.filename,
173+
file2Name: req.params.doc2.filename,
174+
file1Size: req.params.doc1.data.length,
175+
file2Size: req.params.doc2.data.length,
176+
};
177+
});
178+
179+
const boundary = '----TestBoundaryMulti';
180+
const body = buildMultipartBody(boundary, [
181+
{ name: 'doc1', filename: 'a.txt', contentType: 'text/plain', data: Buffer.from('aaa') },
182+
{ name: 'doc2', filename: 'b.txt', contentType: 'text/plain', data: Buffer.from('bbbbb') },
183+
]);
184+
185+
const result = await postMultipart(
186+
`http://localhost:8378/1/functions/multipartMultiFile`,
187+
{
188+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
189+
'X-Parse-Application-Id': 'test',
190+
'X-Parse-REST-API-Key': 'rest',
191+
},
192+
body
193+
);
194+
195+
expect(result.status).toBe(200);
196+
expect(result.data.result.file1Name).toBe('a.txt');
197+
expect(result.data.result.file2Name).toBe('b.txt');
198+
expect(result.data.result.file1Size).toBe(3);
199+
expect(result.data.result.file2Size).toBe(5);
200+
});
201+
202+
it('should handle empty file field from multipart request', async () => {
203+
Parse.Cloud.define('multipartEmptyFile', req => {
204+
return {
205+
filename: req.params.empty.filename,
206+
size: req.params.empty.data.length,
207+
};
208+
});
209+
210+
const boundary = '----TestBoundaryEmpty';
211+
const body = buildMultipartBody(boundary, [
212+
{ name: 'empty', filename: 'empty.bin', contentType: 'application/octet-stream', data: Buffer.alloc(0) },
213+
]);
214+
215+
const result = await postMultipart(
216+
`http://localhost:8378/1/functions/multipartEmptyFile`,
217+
{
218+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
219+
'X-Parse-Application-Id': 'test',
220+
'X-Parse-REST-API-Key': 'rest',
221+
},
222+
body
223+
);
224+
225+
expect(result.status).toBe(200);
226+
expect(result.data.result.filename).toBe('empty.bin');
227+
expect(result.data.result.size).toBe(0);
228+
});
229+
230+
it('should still handle JSON requests as before', async () => {
231+
Parse.Cloud.define('jsonTest', req => {
232+
return { name: req.params.name, count: req.params.count };
233+
});
234+
235+
const result = await Parse.Cloud.run('jsonTest', { name: 'hello', count: 42 });
236+
237+
expect(result.name).toBe('hello');
238+
expect(result.count).toBe(42);
239+
});
240+
241+
it('should reject multipart request exceeding maxUploadSize', async () => {
242+
await reconfigureServer({ maxUploadSize: '1kb' });
243+
244+
Parse.Cloud.define('multipartLarge', req => {
245+
return { ok: true };
246+
});
247+
248+
const boundary = '----TestBoundaryLarge';
249+
const largeData = Buffer.alloc(2 * 1024, 'x');
250+
const body = buildMultipartBody(boundary, [
251+
{ name: 'bigfile', filename: 'large.bin', contentType: 'application/octet-stream', data: largeData },
252+
]);
253+
254+
const result = await postMultipart(
255+
`http://localhost:8378/1/functions/multipartLarge`,
256+
{
257+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
258+
'X-Parse-Application-Id': 'test',
259+
'X-Parse-REST-API-Key': 'rest',
260+
},
261+
body
262+
);
263+
264+
expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE);
265+
});
266+
267+
it('should reject multipart request exceeding maxUploadSize via file stream', async () => {
268+
await reconfigureServer({ maxUploadSize: '1kb' });
269+
270+
Parse.Cloud.define('multipartLargeFile', req => {
271+
return { ok: true };
272+
});
273+
274+
const boundary = '----TestBoundaryLargeFile';
275+
const body = buildMultipartBody(boundary, [
276+
{ name: 'small', value: 'ok' },
277+
{ name: 'bigfile', filename: 'large.bin', contentType: 'application/octet-stream', data: Buffer.alloc(2 * 1024, 'x') },
278+
]);
279+
280+
const result = await postMultipart(
281+
`http://localhost:8378/1/functions/multipartLargeFile`,
282+
{
283+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
284+
'X-Parse-Application-Id': 'test',
285+
'X-Parse-REST-API-Key': 'rest',
286+
},
287+
body
288+
);
289+
290+
expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE);
291+
});
292+
293+
it('should reject malformed multipart body', async () => {
294+
Parse.Cloud.define('multipartMalformed', req => {
295+
return { ok: true };
296+
});
297+
298+
const result = await postMultipart(
299+
`http://localhost:8378/1/functions/multipartMalformed`,
300+
{
301+
'Content-Type': 'multipart/form-data; boundary=----TestBoundaryBad',
302+
'X-Parse-Application-Id': 'test',
303+
'X-Parse-REST-API-Key': 'rest',
304+
},
305+
Buffer.from('this is not valid multipart data')
306+
);
307+
308+
expect(result.data.code).toBe(Parse.Error.INVALID_JSON);
309+
});
310+
311+
it('should not allow prototype pollution via __proto__ field name', async () => {
312+
Parse.Cloud.define('multipartProto', req => {
313+
const obj = {};
314+
return {
315+
polluted: obj.polluted !== undefined,
316+
paramsClean: Object.getPrototypeOf(req.params) === Object.prototype,
317+
};
318+
});
319+
320+
const boundary = '----TestBoundaryProto';
321+
const body = buildMultipartBody(boundary, [
322+
{ name: '__proto__', value: '{"polluted":"yes"}' },
323+
]);
324+
325+
const result = await postMultipart(
326+
`http://localhost:8378/1/functions/multipartProto`,
327+
{
328+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
329+
'X-Parse-Application-Id': 'test',
330+
'X-Parse-REST-API-Key': 'rest',
331+
},
332+
body
333+
);
334+
335+
expect(result.status).toBe(200);
336+
expect(result.data.result.polluted).toBe(false);
337+
expect(result.data.result.paramsClean).toBe(true);
338+
});
339+
340+
it('should not grant master key access via multipart fields', async () => {
341+
const obj = new Parse.Object('SecretClass');
342+
await obj.save(null, { useMasterKey: true });
343+
344+
Parse.Cloud.define('multipartAuthCheck', req => {
345+
return { isMaster: req.master };
346+
});
347+
348+
const boundary = '----TestBoundaryAuth';
349+
const body = buildMultipartBody(boundary, [
350+
{ name: '_MasterKey', value: 'test' },
351+
]);
352+
353+
const result = await postMultipart(
354+
`http://localhost:8378/1/functions/multipartAuthCheck`,
355+
{
356+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
357+
'X-Parse-Application-Id': 'test',
358+
'X-Parse-REST-API-Key': 'rest',
359+
},
360+
body
361+
);
362+
363+
expect(result.status).toBe(200);
364+
expect(result.data.result.isMaster).toBe(false);
365+
});
366+
});

src/ParseServer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ class ParseServer {
329329
new PagesRouter(pages).expressRouter()
330330
);
331331

332-
api.use(express.json({ type: '*/*', limit: maxUploadSize }));
332+
api.use(express.json({ type: req => !req.is('multipart/form-data'), limit: maxUploadSize }));
333333
api.use(middlewares.allowMethodOverride);
334334
api.use(middlewares.handleParseHeaders);
335335
api.use(middlewares.enforceRouteAllowList);

0 commit comments

Comments
 (0)