Skip to content
Open
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
48 changes: 48 additions & 0 deletions spec/Middlewares.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -580,4 +580,52 @@ describe('middlewares', () => {
expect(cache4.get('192.168.0.101')).toBe(undefined);
expect(spyBlockListCheck).toHaveBeenCalledTimes(1);
});

describe('enforceParsedBody', () => {
const createReq = ({ headers = {}, body, isMultipart = false }) => ({
headers,
body,
is: type => (type === 'multipart/form-data' && isMultipart ? type : false),
});

it('rejects a request with a body that was not parsed', () => {
const next = jasmine.createSpy('next');
middlewares.enforceParsedBody(createReq({ headers: { 'content-length': '42' } }), {}, next);
expect(next).toHaveBeenCalledTimes(1);
const error = next.calls.argsFor(0)[0];
expect(error).toEqual(jasmine.any(Parse.Error));
expect(error.code).toBe(Parse.Error.INVALID_JSON);
});

it('rejects a chunked request with a body that was not parsed', () => {
const next = jasmine.createSpy('next');
const req = createReq({ headers: { 'transfer-encoding': 'chunked' } });
middlewares.enforceParsedBody(req, {}, next);
expect(next.calls.argsFor(0)[0]).toEqual(jasmine.any(Parse.Error));
});

it('allows a request with a parsed body', () => {
const next = jasmine.createSpy('next');
const req = createReq({ headers: { 'content-length': '42' }, body: { a: 1 } });
middlewares.enforceParsedBody(req, {}, next);
expect(next).toHaveBeenCalledOnceWith();
});

it('allows a request without a body', () => {
const next = jasmine.createSpy('next');
middlewares.enforceParsedBody(createReq({}), {}, next);
expect(next).toHaveBeenCalledOnceWith();
const emptyReq = createReq({ headers: { 'content-length': '0' } });
middlewares.enforceParsedBody(emptyReq, {}, next);
expect(next).toHaveBeenCalledTimes(2);
expect(next.calls.argsFor(1)).toEqual([]);
});

it('allows a multipart request that is not parsed as JSON', () => {
const next = jasmine.createSpy('next');
const req = createReq({ headers: { 'content-length': '42' }, isMultipart: true });
middlewares.enforceParsedBody(req, {}, next);
expect(next).toHaveBeenCalledOnceWith();
});
});
});
31 changes: 31 additions & 0 deletions spec/ParseAPI.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,37 @@ describe('miscellaneous', () => {
});
});

it('does not create an object if a query sent as POST with _method GET is aborted before its body is parsed', async () => {
// An async config key that takes real time lets the socket close be processed
// before the JSON body parser runs
await reconfigureServer({
publicServerURL: () =>
new Promise(resolve => setTimeout(() => resolve('http://localhost:8378/1'), 50)),
});
const body = JSON.stringify({ where: {}, _method: 'GET' });
const rawRequest =
'POST /1/classes/AbortedQuery HTTP/1.1\r\n' +
'Host: localhost:8378\r\n' +
'X-Parse-Application-Id: test\r\n' +
'X-Parse-Master-Key: test\r\n' +
'Content-Type: application/json\r\n' +
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
'\r\n' +
body;
await new Promise(resolve => {
const socket = require('net').connect(8378, 'localhost', () => {
socket.write(rawRequest);
socket.end();
});
socket.on('data', () => {});
socket.on('error', resolve);
socket.on('close', resolve);
});
await new Promise(resolve => setTimeout(resolve, 200));
const count = await new Parse.Query('AbortedQuery').count({ useMasterKey: true });
expect(count).toBe(0);
});

it('doesnt convert interior keys of objects that use special names', done => {
const obj = new Parse.Object('Obj');
obj.set('val', { createdAt: 'a', updatedAt: 1 });
Expand Down
1 change: 1 addition & 0 deletions src/ParseServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ class ParseServer {
);

api.use(express.json({ type: req => !req.is('multipart/form-data'), limit: maxUploadSize }));
api.use(middlewares.enforceParsedBody);
api.use(middlewares.allowMethodOverride);
api.use(middlewares.handleParseHeaders);
api.use(middlewares.enforceRouteAllowList);
Expand Down
16 changes: 16 additions & 0 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,22 @@ export function allowCrossDomain(appId) {
};
}

/**
* body-parser silently skips a request whose socket is no longer readable when the parser runs,
* e.g. the client closed the connection while an async middleware before it was pending, which
* leaves `req.body` undefined. Such a request must not continue, otherwise a query sent as `POST`
* with `_method: 'GET'` in the body would be routed as the creation of an empty object.
*/
export function enforceParsedBody(req, res, next) {
const hasBody =
req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length']) > 0;
// Multipart bodies are intentionally not parsed by the JSON body parser
if (req.body === undefined && hasBody && !req.is('multipart/form-data')) {
return next(new Parse.Error(Parse.Error.INVALID_JSON, 'Request body could not be read.'));
}
next();
}

export function allowMethodOverride(req, res, next) {
if (req.method === 'POST' && req.body?._method) {
if (typeof req.body._method === 'string') {
Expand Down