fix: Add configurable batch request sub-request limit via option requestComplexity.batchRequestLimit (#10265)

This commit is contained in:
Manuel
2026-03-21 02:20:35 +00:00
committed by GitHub
parent fb9ce5698a
commit 164ed0dd12
9 changed files with 195 additions and 1 deletions
+1
View File
@@ -147,6 +147,7 @@ describe('request complexity', () => {
await reconfigureServer({});
const config = Config.get('test');
expect(config.requestComplexity).toEqual({
batchRequestLimit: -1,
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
+1
View File
@@ -44,6 +44,7 @@ describe('Security Check Groups', () => {
queryDepth: 10,
graphQLDepth: 50,
graphQLFields: 200,
batchRequestLimit: 50,
};
await reconfigureServer(config);
+170
View File
@@ -594,6 +594,176 @@ describe('batch', () => {
});
}
describe('batch request size limit', () => {
it('should reject batch request when sub-requests exceed batchRequestLimit', async () => {
await reconfigureServer({
requestComplexity: { batchRequestLimit: 2 },
});
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers,
body: JSON.stringify({
requests: [
{ method: 'GET', path: '/1/classes/TestClass' },
{ method: 'GET', path: '/1/classes/TestClass' },
{ method: 'GET', path: '/1/classes/TestClass' },
],
}),
})
).toBeRejectedWith(
jasmine.objectContaining({
status: 400,
data: jasmine.objectContaining({
error: jasmine.stringContaining('3'),
}),
})
);
});
it('should allow batch request when sub-requests are within batchRequestLimit', async () => {
await reconfigureServer({
requestComplexity: { batchRequestLimit: 5 },
});
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers,
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } },
{ method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } },
],
}),
});
expect(result.data.length).toEqual(2);
expect(result.data[0].success.objectId).toBeDefined();
expect(result.data[1].success.objectId).toBeDefined();
});
it('should allow batch request at exactly batchRequestLimit', async () => {
await reconfigureServer({
requestComplexity: { batchRequestLimit: 2 },
});
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers,
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } },
{ method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } },
],
}),
});
expect(result.data.length).toEqual(2);
});
it('should not limit batch request when batchRequestLimit is -1 (disabled)', async () => {
await reconfigureServer({
requestComplexity: { batchRequestLimit: -1 },
});
const requests = Array.from({ length: 20 }, (_, i) => ({
method: 'POST',
path: '/1/classes/TestClass',
body: { key: `v${i}` },
}));
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers,
body: JSON.stringify({ requests }),
});
expect(result.data.length).toEqual(20);
});
it('should not limit batch request by default (no requestComplexity configured)', async () => {
const requests = Array.from({ length: 20 }, (_, i) => ({
method: 'POST',
path: '/1/classes/TestClass',
body: { key: `v${i}` },
}));
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers,
body: JSON.stringify({ requests }),
});
expect(result.data.length).toEqual(20);
});
it('should bypass batchRequestLimit for master key requests', async () => {
await reconfigureServer({
requestComplexity: { batchRequestLimit: 2 },
});
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers: {
...headers,
'X-Parse-Master-Key': 'test',
},
body: JSON.stringify({
requests: [
{ method: 'GET', path: '/1/classes/TestClass' },
{ method: 'GET', path: '/1/classes/TestClass' },
{ method: 'GET', path: '/1/classes/TestClass' },
],
}),
});
expect(result.data.length).toEqual(3);
});
it('should bypass batchRequestLimit for maintenance key requests', async () => {
await reconfigureServer({
requestComplexity: { batchRequestLimit: 2 },
});
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers: {
...headers,
'X-Parse-Maintenance-Key': 'testing',
},
body: JSON.stringify({
requests: [
{ method: 'GET', path: '/1/classes/TestClass' },
{ method: 'GET', path: '/1/classes/TestClass' },
{ method: 'GET', path: '/1/classes/TestClass' },
],
}),
});
expect(result.data.length).toEqual(3);
});
it('should include limit in error message when batch exceeds batchRequestLimit', async () => {
await reconfigureServer({
requestComplexity: { batchRequestLimit: 5 },
});
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/batch',
headers,
body: JSON.stringify({
requests: Array.from({ length: 10 }, () => ({
method: 'GET',
path: '/1/classes/TestClass',
})),
}),
})
).toBeRejectedWith(
jasmine.objectContaining({
status: 400,
data: jasmine.objectContaining({
error: jasmine.stringContaining('5'),
}),
})
);
});
});
describe('subrequest path type validation', () => {
it('rejects object path in batch subrequest with proper error instead of 500', async () => {
await expectAsync(
+5
View File
@@ -71,6 +71,11 @@ module.exports = [
changeNewDefault: '200',
solution: "Set 'requestComplexity.graphQLFields' to a positive integer appropriate for your app to limit the number of GraphQL field selections, or to '-1' to disable.",
},
{
optionKey: 'requestComplexity.batchRequestLimit',
changeNewDefault: '100',
solution: "Set 'requestComplexity.batchRequestLimit' to a positive integer appropriate for your app to limit the number of sub-requests per batch request, or to '-1' to disable.",
},
{
optionKey: 'enableProductPurchaseLegacyApi',
changeNewKey: '',
+6
View File
@@ -674,6 +674,12 @@ module.exports.RateLimitOptions = {
},
};
module.exports.RequestComplexityOptions = {
batchRequestLimit: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_BATCH_REQUEST_LIMIT',
help: 'Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('batchRequestLimit'),
default: -1,
},
graphQLDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH',
help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.',
+1
View File
@@ -131,6 +131,7 @@
/**
* @interface RequestComplexityOptions
* @property {Number} batchRequestLimit Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`.
* @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.
* @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`.
* @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`.
+3
View File
@@ -449,6 +449,9 @@ export interface RequestComplexityOptions {
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS
:DEFAULT: -1 */
graphQLFields: ?number;
/* Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
batchRequestLimit: ?number;
}
export interface SecurityOptions {
@@ -145,7 +145,7 @@ class CheckGroupServerConfig extends CheckGroup {
if (!rc) {
throw 1;
}
const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.queryDepth, rc.graphQLDepth, rc.graphQLFields];
const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.queryDepth, rc.graphQLDepth, rc.graphQLFields, rc.batchRequestLimit];
if (values.some(v => v === -1)) {
throw 1;
}
+7
View File
@@ -67,6 +67,13 @@ async function handleBatch(router, req) {
if (!Array.isArray(req.body?.requests)) {
throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array');
}
const batchRequestLimit = req.config?.requestComplexity?.batchRequestLimit ?? -1;
if (batchRequestLimit > -1 && !req.auth?.isMaster && !req.auth?.isMaintenance && req.body.requests.length > batchRequestLimit) {
throw new Parse.Error(
Parse.Error.INVALID_JSON,
`Batch request contains ${req.body.requests.length} sub-requests, which exceeds the limit of ${batchRequestLimit}.`
);
}
for (const restRequest of req.body.requests) {
if (!restRequest || typeof restRequest !== 'object' || typeof restRequest.path !== 'string') {
throw new Parse.Error(Parse.Error.INVALID_JSON, 'batch request path must be a string');