From 9421f036d21bd7fde66fc23979d927bb33c91ae4 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:42:30 +0100 Subject: [PATCH 1/2] feat --- spec/RequestComplexity.spec.js | 110 +++++++++++++++++++++++++++++++++ src/Options/Definitions.js | 6 ++ src/Options/docs.js | 1 + src/Options/index.js | 3 + src/RestQuery.js | 28 +++++++++ 5 files changed, 148 insertions(+) diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index 0a82a12251..13a30bd5c4 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -162,6 +162,7 @@ describe('request complexity', () => { includeDepth: -1, includeCount: -1, subqueryDepth: -1, + subqueryLimit: -1, queryDepth: -1, graphQLDepth: -1, graphQLFields: -1, @@ -732,4 +733,113 @@ describe('request complexity', () => { }); }); }); + + describe('subquery result limit', () => { + let config; + const totalObjects = 5; + const resultLimit = 3; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { subqueryLimit: resultLimit }, + }); + config = Config.get('test'); + // Create target objects + const targets = []; + for (let i = 0; i < totalObjects; i++) { + const obj = new Parse.Object('Target'); + obj.set('value', `v${i}`); + targets.push(obj); + } + await Parse.Object.saveAll(targets); + // Create source objects, each pointing to a target + const sources = []; + for (let i = 0; i < totalObjects; i++) { + const obj = new Parse.Object('Source'); + obj.set('ref', targets[i]); + obj.set('value', targets[i].get('value')); + sources.push(obj); + } + await Parse.Object.saveAll(sources); + }); + + it('should limit $inQuery subquery results', async () => { + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(resultLimit); + }); + + it('should limit $notInQuery subquery results', async () => { + const where = { + ref: { + $notInQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + // With limit, only `resultLimit` targets are excluded, so (totalObjects - resultLimit) sources remain + expect(result.results.length).toBe(totalObjects - resultLimit); + }); + + it('should limit $select subquery results', async () => { + const where = { + value: { + $select: { query: { className: 'Target', where: {} }, key: 'value' }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(resultLimit); + }); + + it('should limit $dontSelect subquery results', async () => { + const where = { + value: { + $dontSelect: { query: { className: 'Target', where: {} }, key: 'value' }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(totalObjects - resultLimit); + }); + + it('should allow unlimited subquery results with master key', async () => { + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.master(config), 'Source', where); + expect(result.results.length).toBe(totalObjects); + }); + + it('should allow unlimited subquery results when subqueryLimit is -1', async () => { + await reconfigureServer({ + requestComplexity: { subqueryLimit: -1 }, + }); + config = Config.get('test'); + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(totalObjects); + }); + + it('should include subqueryLimit in config defaults', async () => { + await reconfigureServer({}); + config = Config.get('test'); + expect(config.requestComplexity.subqueryLimit).toBe(-1); + }); + + it('should accept subqueryLimit in config validation', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { subqueryLimit: 100 }, + }) + ).toBeResolved(); + }); + }); }); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index f2ae088fed..8ebff9fa9f 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -752,6 +752,12 @@ module.exports.RequestComplexityOptions = { action: parsers.numberParser('subqueryDepth'), default: -1, }, + subqueryLimit: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_LIMIT', + help: 'Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('subqueryLimit'), + default: -1, + }, }; module.exports.SecurityOptions = { checkGroups: { diff --git a/src/Options/docs.js b/src/Options/docs.js index edaf366ece..7035c06862 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -144,6 +144,7 @@ * @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`. * @property {Number} queryDepth Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. * @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`. + * @property {Number} subqueryLimit Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`. */ /** diff --git a/src/Options/index.js b/src/Options/index.js index 71e0c7c896..73009f1f10 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -460,6 +460,9 @@ export interface RequestComplexityOptions { /* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`. :DEFAULT: -1 */ subqueryDepth: ?number; + /* Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + subqueryLimit: ?number; /* Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. :DEFAULT: -1 */ queryDepth: ?number; diff --git a/src/RestQuery.js b/src/RestQuery.js index 628490bc77..912c328a66 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -563,6 +563,13 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, @@ -624,6 +631,13 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, @@ -698,6 +712,13 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, @@ -762,6 +783,13 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, From 5aef1cbad680a76304c8b3afaac25d5cb04a733b Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:14:54 +0100 Subject: [PATCH 2/2] test: Add maintenance key bypass test for subqueryLimit --- spec/RequestComplexity.spec.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index 13a30bd5c4..4f1a712218 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -814,6 +814,16 @@ describe('request complexity', () => { expect(result.results.length).toBe(totalObjects); }); + it('should allow unlimited subquery results with maintenance key', async () => { + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.maintenance(config), 'Source', where); + expect(result.results.length).toBe(totalObjects); + }); + it('should allow unlimited subquery results when subqueryLimit is -1', async () => { await reconfigureServer({ requestComplexity: { subqueryLimit: -1 },