From 35ddb125f9eeaa6894bea7b3b9245e7861a0749a Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:43:49 +0200 Subject: [PATCH 1/2] fix: GHSA-cgxm-vr2f-6fj8 --- spec/ParseLiveQueryServer.spec.js | 30 +++++++++ spec/RequestComplexity.spec.js | 89 +++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 25 +++++--- src/RestQuery.js | 25 +++++--- 4 files changed, 153 insertions(+), 16 deletions(-) diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index 62bf33d327..f05d3750a2 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -306,6 +306,36 @@ describe('ParseLiveQueryServer', function () { expect(Client.pushError).toHaveBeenCalled(); }); + it('rejects field-wrapped deeply nested operators exceeding the query depth limit', async () => { + await reconfigureServer({ requestComplexity: { queryDepth: 3 } }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + const parseWebSocket = { clientId }; + // A deep $or hidden inside a field-level $elemMatch must still be counted by the + // LiveQuery query depth guard (parity with the REST validateQueryDepth fix). + let nested = { name: 'x' }; + for (let i = 0; i < 4; i++) { + nested = { $or: [nested] }; + } + const request = { + query: { className: 'test', where: { tags: { $elemMatch: nested } }, keys: ['x'] }, + requestId: 2, + sessionToken: 'sessionToken', + }; + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalledWith( + jasmine.anything(), + Parse.Error.INVALID_QUERY, + jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + false, + 2 + ); + expect(parseLiveQueryServer.subscriptions.size).toBe(0); + }); + it('can handle subscribe command with new query', async () => { const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index 4f1a712218..dede9615a0 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -444,6 +444,95 @@ describe('request complexity', () => { }); }); + describe('query depth bypass via field-wrapped operators', () => { + let config; + + function buildDeepOr(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $or: [where] }; + } + return where; + } + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 3 }, + }); + config = Config.get('test'); + }); + + it('should reject a deeply nested $or wrapped in $elemMatch exceeding depth limit', async () => { + const where = { username: { $elemMatch: buildDeepOr(4) } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject a deeply nested $or wrapped in $not exceeding depth limit', async () => { + const where = { username: { $not: buildDeepOr(4) } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject a deeply nested $or wrapped under a plain field name exceeding depth limit', async () => { + const where = { metadata: buildDeepOr(4) }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow field-wrapped logical operators within depth limit', async () => { + const where = { + username: { + $inQuery: { + className: '_User', + where: { $or: [{ username: 'a' }, { username: 'b' }] }, + }, + }, + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should not count field-level operators that do not nest logical operators toward depth', async () => { + const where = { username: { $in: ['a', 'b'] } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should not exponentially process field-wrapped deeply nested operators when queryDepth is disabled', async () => { + // With queryDepth disabled, the depth guard does not run; the walk over the + // nested $or arrays must still be linear (not O(2^n)) so a single small request + // cannot hang the event loop. + await reconfigureServer({ + requestComplexity: { queryDepth: -1 }, + }); + config = Config.get('test'); + const where = { username: { $elemMatch: buildDeepOr(26) } }; + const start = Date.now(); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejected(); + expect(Date.now() - start).toBeLessThan(5000); + }, 60000); + }); + describe('include limits', () => { let config; diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index ab70fc5acc..23377dfaee 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -1037,25 +1037,32 @@ class ParseLiveQueryServer { const rc = appConfig.requestComplexity; if (rc && rc.queryDepth !== -1) { const maxDepth = rc.queryDepth; - const checkDepth = (where: any, depth: number) => { + const checkDepth = (node: any, depth: number) => { if (depth > maxDepth) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` ); } - if (typeof where !== 'object' || where === null) { + if (node === null || typeof node !== 'object') { return; } - for (const op of ['$or', '$and', '$nor']) { - if (where[op] !== undefined && !Array.isArray(where[op])) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + if (Array.isArray(node)) { + for (const item of node) { + checkDepth(item, depth); } - if (Array.isArray(where[op])) { - for (const subQuery of where[op]) { - checkDepth(subQuery, depth + 1); - } + return; + } + // Descend into every value so that logical operators ($or/$and/$nor) + // nested under field-level operators (e.g. $elemMatch, $not) or plain + // field names are still counted. Only logical operators increase the + // depth, which preserves the documented meaning of `queryDepth`. + for (const key of Object.keys(node)) { + const isLogical = key === '$or' || key === '$and' || key === '$nor'; + if (isLogical && !Array.isArray(node[key])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${key} must be an array`); } + checkDepth(node[key], isLogical ? depth + 1 : depth); } }; checkDepth(request.query.where, 0); diff --git a/src/RestQuery.js b/src/RestQuery.js index 8456db6a3d..51a65ce17d 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -359,22 +359,29 @@ _UnsafeRestQuery.prototype.validateQueryDepth = function () { return; } const maxDepth = rc.queryDepth; - const checkDepth = (where, depth) => { + const checkDepth = (node, depth) => { if (depth > maxDepth) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` ); } - if (typeof where !== 'object' || where === null) { + if (node === null || typeof node !== 'object') { return; } - for (const op of ['$or', '$and', '$nor']) { - if (Array.isArray(where[op])) { - for (const subQuery of where[op]) { - checkDepth(subQuery, depth + 1); - } + if (Array.isArray(node)) { + for (const item of node) { + checkDepth(item, depth); } + return; + } + // Descend into every value so that logical operators ($or/$and/$nor) nested + // under field-level operators (e.g. $elemMatch, $not) or plain field names are + // still counted. Only logical operators increase the depth, which preserves the + // documented meaning of `queryDepth`. + for (const key of Object.keys(node)) { + const isLogical = key === '$or' || key === '$and' || key === '$nor'; + checkDepth(node[key], isLogical ? depth + 1 : depth); } }; checkDepth(this.restWhere, 0); @@ -1361,6 +1368,10 @@ function findObjectWithKey(root, key) { return answer; } } + // Arrays are fully traversed above; returning here avoids re-walking the same + // elements through the `for (subkey in root)` loop below, which would make this + // function O(2^n) for nested arrays (e.g. deeply nested $or/$and/$nor). + return; } if (root && root[key]) { return root; From d4050ee1fe01815b8793bebd24062a669036e5cf Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:57:21 +0200 Subject: [PATCH 2/2] test: cover LiveQuery query depth guard branches --- spec/ParseLiveQueryServer.spec.js | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index f05d3750a2..3613997a80 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -336,6 +336,46 @@ describe('ParseLiveQueryServer', function () { expect(parseLiveQueryServer.subscriptions.size).toBe(0); }); + it('rejects a non-array value for a logical operator on subscribe', async () => { + await reconfigureServer({ requestComplexity: { queryDepth: 3 } }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + const parseWebSocket = { clientId }; + const request = { + query: { className: 'test', where: { $or: 'not-an-array' }, keys: ['x'] }, + requestId: 3, + sessionToken: 'sessionToken', + }; + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalledWith( + jasmine.anything(), + Parse.Error.INVALID_QUERY, + jasmine.stringMatching(/\$or must be an array/), + false, + 3 + ); + expect(parseLiveQueryServer.subscriptions.size).toBe(0); + }); + + it('allows null values nested in the query within the depth limit', async () => { + await reconfigureServer({ requestComplexity: { queryDepth: 3 } }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + const parseWebSocket = { clientId }; + const request = { + query: { className: 'test', where: { $or: [{ name: null }] }, keys: ['x'] }, + requestId: 4, + sessionToken: 'sessionToken', + }; + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + expect(parseLiveQueryServer.subscriptions.size).toBe(1); + }); + it('can handle subscribe command with new query', async () => { const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client