From 1017a933f438b1632fe97f6ffdf070289aba0a35 Mon Sep 17 00:00:00 2001 From: beiguancyc <373535088@qq.com> Date: Thu, 22 Oct 2026 09:32:53 +0800 Subject: [PATCH] fix: prevent an aborted query from creating an empty object A query sent as POST with _method GET is routed as a create when the connection closes before the body is parsed, which saves an empty object. Closes #10815 Co-authored-by: Cursor --- spec/Middlewares.spec.js | 48 ++++++++++++++++++++++++++++++++++++++++ spec/ParseAPI.spec.js | 31 ++++++++++++++++++++++++++ src/ParseServer.ts | 1 + src/middlewares.js | 16 ++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index ff6fa003b5..6fc131951c 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -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(); + }); + }); }); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index cd0221e3ad..ddff03e214 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -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 }); diff --git a/src/ParseServer.ts b/src/ParseServer.ts index 65d537ae68..37e7ef77ff 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -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); diff --git a/src/middlewares.js b/src/middlewares.js index 172b2e1fd1..db602fd93b 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -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') {