Skip to content

Commit 278808d

Browse files
committed
feat: max depth and max fields protection system
1 parent a85ba19 commit 278808d

File tree

9 files changed

+1428
-1
lines changed

9 files changed

+1428
-1
lines changed

spec/ParseGraphQLQueryComplexity.spec.js

Lines changed: 569 additions & 0 deletions
Large diffs are not rendered by default.

spec/RestQuery.spec.js

Lines changed: 648 additions & 0 deletions
Large diffs are not rendered by default.

src/Config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export class Config {
132132
databaseOptions,
133133
extendSessionOnUse,
134134
allowClientClassCreation,
135+
maxQueryComplexity,
136+
maxGraphQLQueryComplexity,
135137
}) {
136138
if (masterKey === readOnlyMasterKey) {
137139
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -173,6 +175,7 @@ export class Config {
173175
this.validateDatabaseOptions(databaseOptions);
174176
this.validateCustomPages(customPages);
175177
this.validateAllowClientClassCreation(allowClientClassCreation);
178+
this.validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity);
176179
}
177180

178181
static validateCustomPages(customPages) {
@@ -230,6 +233,17 @@ export class Config {
230233
}
231234
}
232235

236+
static validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity) {
237+
if (maxQueryComplexity && maxGraphQLQueryComplexity) {
238+
if (maxQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) {
239+
throw new Error('maxQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth');
240+
}
241+
if (maxQueryComplexity.fields >= maxGraphQLQueryComplexity.fields) {
242+
throw new Error('maxQueryComplexity.fields must be less than maxGraphQLQueryComplexity.fields');
243+
}
244+
}
245+
}
246+
233247
static validateSecurityOptions(security) {
234248
if (Object.prototype.toString.call(security) !== '[object Object]') {
235249
throw 'Parse Server option security must be an object.';

src/GraphQL/ParseGraphQLServer.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import requiredParameter from '../requiredParameter';
1111
import defaultLogger from '../logger';
1212
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
1313
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
14+
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
1415

1516

1617
const IntrospectionControlPlugin = (publicIntrospection) => ({
@@ -98,14 +99,24 @@ class ParseGraphQLServer {
9899
return this._server;
99100
}
100101
const { schema, context } = await this._getGraphQLOptions();
102+
const plugins = [
103+
ApolloServerPluginCacheControlDisabled(),
104+
IntrospectionControlPlugin(this.config.graphQLPublicIntrospection),
105+
];
106+
107+
// Add complexity validation plugin if configured
108+
if (this.parseServer.config.maxGraphQLQueryComplexity) {
109+
plugins.push(createComplexityValidationPlugin(this.parseServer.config));
110+
}
111+
101112
const apollo = new ApolloServer({
102113
csrfPrevention: {
103114
// See https://www.apollographql.com/docs/router/configuration/csrf/
104115
// needed since we use graphql upload
105116
requestHeaders: ['X-Parse-Application-Id'],
106117
},
107118
introspection: this.config.graphQLPublicIntrospection,
108-
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
119+
plugins,
109120
schema,
110121
});
111122
await apollo.start();
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { GraphQLError, getOperationAST, Kind } from 'graphql';
2+
3+
/**
4+
* Calculate the maximum depth and fields (field count) of a GraphQL query
5+
* @param {DocumentNode} document - The GraphQL document AST
6+
* @returns {{ depth: number, fields: number }} Maximum depth and total fields
7+
*/
8+
function calculateQueryComplexity(document) {
9+
const operationAST = getOperationAST(document);
10+
if (!operationAST || !operationAST.selectionSet) {
11+
return { depth: 0, fields: 0 };
12+
}
13+
14+
// Build fragment definition map
15+
const fragments = {};
16+
if (document.definitions) {
17+
document.definitions.forEach(def => {
18+
if (def.kind === Kind.FRAGMENT_DEFINITION) {
19+
fragments[def.name.value] = def;
20+
}
21+
});
22+
}
23+
24+
let maxDepth = 0;
25+
let fields = 0;
26+
27+
function visitSelectionSet(selectionSet, depth) {
28+
if (!selectionSet || !selectionSet.selections) {
29+
return;
30+
}
31+
32+
selectionSet.selections.forEach(selection => {
33+
if (selection.kind === Kind.FIELD) {
34+
fields++;
35+
maxDepth = Math.max(maxDepth, depth);
36+
if (selection.selectionSet) {
37+
visitSelectionSet(selection.selectionSet, depth + 1);
38+
}
39+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
40+
// Inline fragments don't add depth, just traverse their selections
41+
visitSelectionSet(selection.selectionSet, depth);
42+
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
43+
const fragmentName = selection.name.value;
44+
const fragment = fragments[fragmentName];
45+
// Note: Circular fragments are already prevented by GraphQL validation (NoFragmentCycles rule)
46+
// so we don't need to check for cycles here
47+
if (fragment && fragment.selectionSet) {
48+
visitSelectionSet(fragment.selectionSet, depth);
49+
}
50+
}
51+
});
52+
}
53+
54+
visitSelectionSet(operationAST.selectionSet, 1);
55+
return { depth: maxDepth, fields };
56+
}
57+
58+
/**
59+
* Create a GraphQL complexity validation plugin for Apollo Server
60+
* Computes depth and total field count directly from the parsed GraphQL document
61+
* @param {Object} config - Parse Server config object
62+
* @returns {Object} Apollo Server plugin
63+
*/
64+
export function createComplexityValidationPlugin(config) {
65+
return {
66+
requestDidStart: () => ({
67+
didResolveOperation: async (requestContext) => {
68+
const { document } = requestContext;
69+
const auth = requestContext.contextValue?.auth;
70+
71+
// Skip validation for master/maintenance keys
72+
if (auth?.isMaster || auth?.isMaintenance) {
73+
return;
74+
}
75+
76+
// Skip if no complexity limits are configured
77+
if (!config.maxGraphQLQueryComplexity) {
78+
return;
79+
}
80+
81+
// Skip if document is not available
82+
if (!document) {
83+
return;
84+
}
85+
86+
const maxGraphQLQueryComplexity = config.maxGraphQLQueryComplexity;
87+
88+
// Calculate depth and fields in a single pass for performance
89+
const { depth, fields } = calculateQueryComplexity(document);
90+
91+
// Validate fields (field count)
92+
if (maxGraphQLQueryComplexity.fields && fields > maxGraphQLQueryComplexity.fields) {
93+
throw new GraphQLError(
94+
`Number of fields selected exceeds maximum allowed`,
95+
);
96+
}
97+
98+
// Validate maximum depth
99+
if (maxGraphQLQueryComplexity.depth && depth > maxGraphQLQueryComplexity.depth) {
100+
throw new GraphQLError(
101+
`Query depth exceeds maximum allowed depth`,
102+
);
103+
}
104+
},
105+
}),
106+
};
107+
}

src/Options/Definitions.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,12 @@ module.exports.ParseServerOptions = {
396396
'(Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server.',
397397
action: parsers.numberParser('masterKeyTtl'),
398398
},
399+
maxGraphQLQueryComplexity: {
400+
env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY',
401+
help:
402+
'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
403+
action: parsers.objectParser,
404+
},
399405
maxLimit: {
400406
env: 'PARSE_SERVER_MAX_LIMIT',
401407
help: 'Max value for limit option on queries, defaults to unlimited',
@@ -407,6 +413,12 @@ module.exports.ParseServerOptions = {
407413
"Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)",
408414
action: parsers.numberOrStringParser('maxLogFiles'),
409415
},
416+
maxQueryComplexity: {
417+
env: 'PARSE_SERVER_MAX_QUERY_COMPLEXITY',
418+
help:
419+
'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
420+
action: parsers.objectParser,
421+
},
410422
maxUploadSize: {
411423
env: 'PARSE_SERVER_MAX_UPLOAD_SIZE',
412424
help: 'Max file size for uploads, defaults to 20mb',

src/Options/docs.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ type RequestKeywordDenylist = {
4343
key: string | any,
4444
value: any,
4545
};
46+
type QueryComplexityOptions = {
47+
depth: number,
48+
fields: number,
49+
};
4650

4751
export interface ParseServerOptions {
4852
/* Your Parse Application ID
@@ -347,6 +351,22 @@ export interface ParseServerOptions {
347351
rateLimit: ?(RateLimitOptions[]);
348352
/* Options to customize the request context using inversion of control/dependency injection.*/
349353
requestContextMiddleware: ?(req: any, res: any, next: any) => void;
354+
/* Maximum query complexity for REST API includes. Controls depth and number of include fields.
355+
* Format: { depth: number, fields: number }
356+
* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)
357+
* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)
358+
* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values
359+
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
360+
*/
361+
maxQueryComplexity: ?QueryComplexityOptions;
362+
/* Maximum query complexity for GraphQL queries. Controls depth and number of operations.
363+
* Format: { depth: number, fields: number }
364+
* - depth: Maximum depth of nested field selections
365+
* - fields: Maximum number of operations (queries/mutations) in a single request
366+
* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values
367+
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
368+
*/
369+
maxGraphQLQueryComplexity: ?QueryComplexityOptions;
350370
}
351371

352372
export interface RateLimitOptions {

src/RestQuery.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,18 @@ function _UnsafeRestQuery(
207207
this.doCount = true;
208208
break;
209209
case 'includeAll':
210+
// Block includeAll if maxQueryComplexity is configured for non-master users
211+
if (
212+
!this.auth.isMaster &&
213+
!this.auth.isMaintenance &&
214+
this.config.maxQueryComplexity &&
215+
(this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields)
216+
) {
217+
throw new Parse.Error(
218+
Parse.Error.INVALID_QUERY,
219+
'includeAll is not allowed when query complexity limits are configured'
220+
);
221+
}
210222
this.includeAll = true;
211223
break;
212224
case 'explain':
@@ -236,6 +248,18 @@ function _UnsafeRestQuery(
236248
case 'include': {
237249
const paths = restOptions.include.split(',');
238250
if (paths.includes('*')) {
251+
// Block includeAll if maxQueryComplexity is configured for non-master users
252+
if (
253+
!this.auth.isMaster &&
254+
!this.auth.isMaintenance &&
255+
this.config.maxQueryComplexity &&
256+
(this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields)
257+
) {
258+
throw new Parse.Error(
259+
Parse.Error.INVALID_QUERY,
260+
'includeAll is not allowed when query complexity limits are configured'
261+
);
262+
}
239263
this.includeAll = true;
240264
break;
241265
}
@@ -270,6 +294,26 @@ function _UnsafeRestQuery(
270294
throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad option: ' + option);
271295
}
272296
}
297+
298+
// Validate query complexity for REST includes
299+
if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxQueryComplexity && this.include && this.include.length > 0) {
300+
const fieldsCount = this.include.length;
301+
302+
if (this.config.maxQueryComplexity.fields && fieldsCount > this.config.maxQueryComplexity.fields) {
303+
throw new Parse.Error(
304+
Parse.Error.INVALID_QUERY,
305+
`Number of include fields exceeds maximum allowed`
306+
);
307+
}
308+
309+
const depth = Math.max(...this.include.map(path => path.length));
310+
if (this.config.maxQueryComplexity.depth && depth > this.config.maxQueryComplexity.depth) {
311+
throw new Parse.Error(
312+
Parse.Error.INVALID_QUERY,
313+
`Include depth exceeds maximum allowed`
314+
);
315+
}
316+
}
273317
}
274318

275319
// A convenient method to perform all the steps of processing a query

0 commit comments

Comments
 (0)