Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
664 changes: 664 additions & 0 deletions spec/ParseGraphQLQueryComplexity.spec.js

Large diffs are not rendered by default.

654 changes: 654 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,
maxIncludeQueryComplexity,
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(maxIncludeQueryComplexity, maxGraphQLQueryComplexity);
}

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

static validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity) {
if (maxIncludeQueryComplexity && maxGraphQLQueryComplexity) {
if (maxIncludeQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) {
throw new Error('maxIncludeQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth');
}
if (maxIncludeQueryComplexity.count >= maxGraphQLQueryComplexity.fields) {
throw new Error('maxIncludeQueryComplexity.count 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
127 changes: 127 additions & 0 deletions src/GraphQL/helpers/queryComplexity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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
* @param {string} operationName - Optional operation name to select from multi-operation documents
* @param {Object} maxLimits - Optional maximum limits for early exit optimization
* @param {number} maxLimits.depth - Maximum depth allowed
* @param {number} maxLimits.fields - Maximum fields allowed
* @returns {{ depth: number, fields: number }} Maximum depth and total fields
*/
function calculateQueryComplexity(document, operationName, maxLimits = {}) {
const operationAST = getOperationAST(document, operationName);
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);

// Early exit optimization: throw immediately if limits are exceeded
if (maxLimits.fields && fields > maxLimits.fields) {
throw new GraphQLError(
`Number of fields selected exceeds maximum allowed`,
{
extensions: {
http: {
status: 403,
},
}
}
);
}

if (maxLimits.depth && maxDepth > maxLimits.depth) {
throw new GraphQLError(
`Query depth exceeds maximum allowed depth`,
{
extensions: {
http: {
status: 403,
},
}
}
);
}

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, operationName } = 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
// Pass max limits for early exit optimization - will throw immediately if exceeded
// SECURITY: operationName is crucial for multi-operation documents to validate the correct operation
calculateQueryComplexity(document, operationName, maxGraphQLQueryComplexity);
},
}),
};
}
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,18 @@ 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 field selections.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of field selections in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
action: parsers.objectParser,
},
maxIncludeQueryComplexity: {
env: 'PARSE_SERVER_MAX_INCLUDE_QUERY_COMPLEXITY',
help:
'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity 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 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.

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

export interface ParseServerOptions {
/* Your Parse Application ID
Expand Down Expand Up @@ -347,6 +355,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, count: number }
* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)
* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)
* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
*/
maxIncludeQueryComplexity: ?IncludeComplexityOptions;
/* Maximum query complexity for GraphQL queries. Controls depth and number of field selections.
* Format: { depth: number, fields: number }
* - depth: Maximum depth of nested field selections
* - fields: Maximum number of field selections in a single request
* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
*/
maxGraphQLQueryComplexity: ?GraphQLQueryComplexityOptions;
}

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 maxIncludeQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxIncludeQueryComplexity &&
(this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count)
) {
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 maxIncludeQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxIncludeQueryComplexity &&
(this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count)
) {
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.maxIncludeQueryComplexity && this.include && this.include.length > 0) {
const includeCount = this.include.length;

if (this.config.maxIncludeQueryComplexity.count && includeCount > this.config.maxIncludeQueryComplexity.count) {
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.maxIncludeQueryComplexity.depth && depth > this.config.maxIncludeQueryComplexity.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
Loading