Skip to content
569 changes: 569 additions & 0 deletions spec/ParseGraphQLQueryComplexity.spec.js

Large diffs are not rendered by default.

648 changes: 648 additions & 0 deletions spec/RestQuery.spec.js

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export class Config {
databaseOptions,
extendSessionOnUse,
allowClientClassCreation,
maxQueryComplexity,
maxGraphQLQueryComplexity,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
Expand Down Expand Up @@ -173,6 +175,7 @@ export class Config {
this.validateDatabaseOptions(databaseOptions);
this.validateCustomPages(customPages);
this.validateAllowClientClassCreation(allowClientClassCreation);
this.validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity);
}

static validateCustomPages(customPages) {
Expand Down Expand Up @@ -230,6 +233,17 @@ export class Config {
}
}

static validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity) {
if (maxQueryComplexity && maxGraphQLQueryComplexity) {
if (maxQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) {
throw new Error('maxQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth');
}
if (maxQueryComplexity.fields >= maxGraphQLQueryComplexity.fields) {
throw new Error('maxQueryComplexity.fields must be less than maxGraphQLQueryComplexity.fields');
}
}
}

static validateSecurityOptions(security) {
if (Object.prototype.toString.call(security) !== '[object Object]') {
throw 'Parse Server option security must be an object.';
Expand Down
13 changes: 12 additions & 1 deletion src/GraphQL/ParseGraphQLServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import requiredParameter from '../requiredParameter';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
import { createComplexityValidationPlugin } from './helpers/queryComplexity';


const IntrospectionControlPlugin = (publicIntrospection) => ({
Expand Down Expand Up @@ -98,14 +99,24 @@ class ParseGraphQLServer {
return this._server;
}
const { schema, context } = await this._getGraphQLOptions();
const plugins = [
ApolloServerPluginCacheControlDisabled(),
IntrospectionControlPlugin(this.config.graphQLPublicIntrospection),
];

// Add complexity validation plugin if configured
if (this.parseServer.config.maxGraphQLQueryComplexity) {
plugins.push(createComplexityValidationPlugin(this.parseServer.config));
}

const apollo = new ApolloServer({
csrfPrevention: {
// See https://www.apollographql.com/docs/router/configuration/csrf/
// needed since we use graphql upload
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: this.config.graphQLPublicIntrospection,
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
plugins,
schema,
});
await apollo.start();
Expand Down
107 changes: 107 additions & 0 deletions src/GraphQL/helpers/queryComplexity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { GraphQLError, getOperationAST, Kind } from 'graphql';

/**
* Calculate the maximum depth and fields (field count) of a GraphQL query
* @param {DocumentNode} document - The GraphQL document AST
* @returns {{ depth: number, fields: number }} Maximum depth and total fields
*/
function calculateQueryComplexity(document) {
const operationAST = getOperationAST(document);
if (!operationAST || !operationAST.selectionSet) {
return { depth: 0, fields: 0 };
}

// Build fragment definition map
const fragments = {};
if (document.definitions) {
document.definitions.forEach(def => {
if (def.kind === Kind.FRAGMENT_DEFINITION) {
fragments[def.name.value] = def;
}
});
}

let maxDepth = 0;
let fields = 0;

function visitSelectionSet(selectionSet, depth) {
if (!selectionSet || !selectionSet.selections) {
return;
}

selectionSet.selections.forEach(selection => {
if (selection.kind === Kind.FIELD) {
fields++;
maxDepth = Math.max(maxDepth, depth);
if (selection.selectionSet) {
visitSelectionSet(selection.selectionSet, depth + 1);
}
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
// Inline fragments don't add depth, just traverse their selections
visitSelectionSet(selection.selectionSet, depth);
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
const fragmentName = selection.name.value;
const fragment = fragments[fragmentName];
// Note: Circular fragments are already prevented by GraphQL validation (NoFragmentCycles rule)
// so we don't need to check for cycles here
if (fragment && fragment.selectionSet) {
visitSelectionSet(fragment.selectionSet, depth);
}
}
});
}

visitSelectionSet(operationAST.selectionSet, 1);
return { depth: maxDepth, fields };
}

/**
* Create a GraphQL complexity validation plugin for Apollo Server
* Computes depth and total field count directly from the parsed GraphQL document
* @param {Object} config - Parse Server config object
* @returns {Object} Apollo Server plugin
*/
export function createComplexityValidationPlugin(config) {
return {
requestDidStart: () => ({
didResolveOperation: async (requestContext) => {
const { document } = requestContext;
const auth = requestContext.contextValue?.auth;

// Skip validation for master/maintenance keys
if (auth?.isMaster || auth?.isMaintenance) {
return;
}

// Skip if no complexity limits are configured
if (!config.maxGraphQLQueryComplexity) {
return;
}

// Skip if document is not available
if (!document) {
return;
}

const maxGraphQLQueryComplexity = config.maxGraphQLQueryComplexity;

// Calculate depth and fields in a single pass for performance
const { depth, fields } = calculateQueryComplexity(document);

// Validate fields (field count)
if (maxGraphQLQueryComplexity.fields && fields > maxGraphQLQueryComplexity.fields) {
throw new GraphQLError(
`Number of fields selected exceeds maximum allowed`,
);
}

// Validate maximum depth
if (maxGraphQLQueryComplexity.depth && depth > maxGraphQLQueryComplexity.depth) {
throw new GraphQLError(
`Query depth exceeds maximum allowed depth`,
);
}
},
}),
};
}
12 changes: 12 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,12 @@ module.exports.ParseServerOptions = {
'(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.',
action: parsers.numberParser('masterKeyTtl'),
},
maxGraphQLQueryComplexity: {
env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY',
help:
'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.',
action: parsers.objectParser,
},
maxLimit: {
env: 'PARSE_SERVER_MAX_LIMIT',
help: 'Max value for limit option on queries, defaults to unlimited',
Expand All @@ -407,6 +413,12 @@ module.exports.ParseServerOptions = {
"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)",
action: parsers.numberOrStringParser('maxLogFiles'),
},
maxQueryComplexity: {
env: 'PARSE_SERVER_MAX_QUERY_COMPLEXITY',
help:
'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.',
action: parsers.objectParser,
},
maxUploadSize: {
env: 'PARSE_SERVER_MAX_UPLOAD_SIZE',
help: 'Max file size for uploads, defaults to 20mb',
Expand Down
2 changes: 2 additions & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ type RequestKeywordDenylist = {
key: string | any,
value: any,
};
type QueryComplexityOptions = {
depth: number,
fields: number,
};

export interface ParseServerOptions {
/* Your Parse Application ID
Expand Down Expand Up @@ -347,6 +351,22 @@ export interface ParseServerOptions {
rateLimit: ?(RateLimitOptions[]);
/* Options to customize the request context using inversion of control/dependency injection.*/
requestContextMiddleware: ?(req: any, res: any, next: any) => void;
/* 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.
*/
maxQueryComplexity: ?QueryComplexityOptions;
/* 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.
*/
maxGraphQLQueryComplexity: ?QueryComplexityOptions;
}

export interface RateLimitOptions {
Expand Down
44 changes: 44 additions & 0 deletions src/RestQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ function _UnsafeRestQuery(
this.doCount = true;
break;
case 'includeAll':
// Block includeAll if maxQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxQueryComplexity &&
(this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields)
) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
'includeAll is not allowed when query complexity limits are configured'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a sensible constraint? includeAll is a logical feature while maxIncludeQueryComplexity is a safety limit, so maybe we should treat them independently. How about allowing includeAll even if maxIncludeQueryComplexity is set, but throw Parse.Error.INVALID_QUERY error if maxIncludeQueryComplexity is hit?

);
}
this.includeAll = true;
break;
case 'explain':
Expand Down Expand Up @@ -236,6 +248,18 @@ function _UnsafeRestQuery(
case 'include': {
const paths = restOptions.include.split(',');
if (paths.includes('*')) {
// Block includeAll if maxQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxQueryComplexity &&
(this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields)
) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
'includeAll is not allowed when query complexity limits are configured'
);
}
this.includeAll = true;
break;
}
Expand Down Expand Up @@ -270,6 +294,26 @@ function _UnsafeRestQuery(
throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad option: ' + option);
}
}

// Validate query complexity for REST includes
if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxQueryComplexity && this.include && this.include.length > 0) {
const fieldsCount = this.include.length;

if (this.config.maxQueryComplexity.fields && fieldsCount > this.config.maxQueryComplexity.fields) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Number of include fields exceeds maximum allowed`
);
}

const depth = Math.max(...this.include.map(path => path.length));
if (this.config.maxQueryComplexity.depth && depth > this.config.maxQueryComplexity.depth) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Include depth exceeds maximum allowed`
);
}
}
}

// A convenient method to perform all the steps of processing a query
Expand Down
Loading