mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: Add configurable batch request sub-request limit via option requestComplexity.batchRequestLimit (#10265)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('Security Check Groups', () => {
|
||||
queryDepth: 10,
|
||||
graphQLDepth: 50,
|
||||
graphQLFields: 200,
|
||||
batchRequestLimit: 50,
|
||||
};
|
||||
await reconfigureServer(config);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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`.',
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user