Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
);
}
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