diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 0b7dcdac3d..350492f4f1 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -22,6 +22,7 @@ const nestedOptionTypes = [ 'PagesOptions', 'PagesRoute', 'PasswordPolicyOptions', + 'RequestComplexityOptions', 'SecurityOptions', 'SchemaOptions', 'LogLevels', @@ -45,6 +46,7 @@ const nestedOptionEnvPrefix = { ParseServerOptions: 'PARSE_SERVER_', PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_', RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_', + RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_', SchemaOptions: 'PARSE_SERVER_SCHEMA_', SecurityOptions: 'PARSE_SERVER_SECURITY_', }; diff --git a/spec/GraphQLQueryComplexity.spec.js b/spec/GraphQLQueryComplexity.spec.js new file mode 100644 index 0000000000..976cc761f4 --- /dev/null +++ b/spec/GraphQLQueryComplexity.spec.js @@ -0,0 +1,181 @@ +'use strict'; + +const http = require('http'); +const express = require('express'); +const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); +require('./helper'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); + +describe('graphql query complexity', () => { + let httpServer; + let graphQLServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQL(serverOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + graphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + }); + graphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve)); + } + + async function graphqlRequest(query, requestHeaders = headers) { + const response = await fetch('http://localhost:13378/graphql', { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ query }), + }); + return response.json(); + } + + // Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4) + function buildDeepQuery() { + return '{ users { edges { node { objectId } } } }'; + } + + function buildWideQuery(fieldCount) { + const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n '); + return `{ users { edges { node { ${fields} } } } }`; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); + + describe('depth limit', () => { + it('should reject query exceeding depth limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /GraphQL query depth of \d+ exceeds maximum allowed depth of 3/ + ); + }); + + it('should allow query within depth limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 10 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + + it('should allow deep query with master key', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3 }, + }); + const result = await graphqlRequest(buildDeepQuery(), { + ...headers, + 'X-Parse-Master-Key': 'test', + }); + expect(result.errors).toBeUndefined(); + }); + + it('should allow unlimited depth when graphQLDepth is -1', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: -1 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + }); + + describe('fields limit', () => { + it('should reject query exceeding fields limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 5 }, + }); + const result = await graphqlRequest(buildWideQuery(10)); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/ + ); + }); + + it('should allow query within fields limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 200 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + + it('should allow wide query with master key', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 5 }, + }); + const result = await graphqlRequest(buildWideQuery(10), { + ...headers, + 'X-Parse-Master-Key': 'test', + }); + expect(result.errors).toBeUndefined(); + }); + + it('should count fragment fields at each spread location', async () => { + // With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8 + // With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7 + // Set limit to 7 so incorrect counting passes but correct counting rejects + await setupGraphQL({ + requestComplexity: { graphQLFields: 7 }, + }); + const result = await graphqlRequest(` + fragment UserFields on User { objectId } + { + a1: users { edges { node { ...UserFields } } } + a2: users { edges { node { ...UserFields } } } + } + `); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/ + ); + }); + + it('should count inline fragment fields toward depth and field limits', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 3 }, + }); + // Inline fragment adds fields without increasing depth: + // users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) } + const result = await graphqlRequest(`{ + users { + edges { + ... on UserEdge { + node { + objectId + } + } + } + } + }`); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/ + ); + }); + + it('should allow unlimited fields when graphQLFields is -1', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: -1 }, + }); + const result = await graphqlRequest(buildWideQuery(50)); + expect(result.errors).toBeUndefined(); + }); + }); +}); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index a767f413b3..b0bf131ef8 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -9242,6 +9242,12 @@ describe('ParseGraphQLServer', () => { }); it_only_db('mongo')('should support deep nested creation', async () => { + parseServer = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + requestComplexity: { includeDepth: 10 }, + }); + await createGQLFromParseServer(parseServer); const team = new Parse.Object('Team'); team.set('name', 'imATeam1'); await team.save(); diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js new file mode 100644 index 0000000000..6ee159f548 --- /dev/null +++ b/spec/RequestComplexity.spec.js @@ -0,0 +1,327 @@ +'use strict'; + +const Config = require('../lib/Config'); +const auth = require('../lib/Auth'); +const rest = require('../lib/rest'); + +describe('request complexity', () => { + function buildNestedInQuery(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $inQuery: { className, where } } }; + } + return where; + } + + function buildNestedNotInQuery(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $notInQuery: { className, where } } }; + } + return where; + } + + function buildNestedSelect(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $select: { query: { className, where }, key: 'username' } } }; + } + return where; + } + + function buildNestedDontSelect(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $dontSelect: { query: { className, where }, key: 'username' } } }; + } + return where; + } + + describe('config validation', () => { + it('should accept valid requestComplexity config', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { + includeDepth: 10, + includeCount: 100, + subqueryDepth: 5, + graphQLDepth: 15, + graphQLFields: 300, + }, + }) + ).toBeResolved(); + }); + + it('should accept -1 to disable a specific limit', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, + graphQLDepth: -1, + graphQLFields: -1, + }, + }) + ).toBeResolved(); + }); + + it('should reject value of 0', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { includeDepth: 0 }, + }) + ).toBeRejectedWith( + new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.') + ); + }); + + it('should reject non-integer values', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { includeDepth: 3.5 }, + }) + ).toBeRejectedWith( + new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.') + ); + }); + + it('should reject unknown properties', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { unknownProp: 5 }, + }) + ).toBeRejectedWith( + new Error("requestComplexity contains unknown property 'unknownProp'.") + ); + }); + + it('should reject non-object values', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: 'invalid', + }) + ).toBeRejectedWith(new Error('requestComplexity must be an object.')); + }); + + it('should apply defaults for missing properties', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3 }, + }); + const config = Config.get('test'); + expect(config.requestComplexity.includeDepth).toBe(3); + expect(config.requestComplexity.includeCount).toBe(50); + expect(config.requestComplexity.subqueryDepth).toBe(5); + expect(config.requestComplexity.graphQLDepth).toBe(50); + expect(config.requestComplexity.graphQLFields).toBe(200); + }); + + it('should apply full defaults when not configured', async () => { + await reconfigureServer({}); + const config = Config.get('test'); + expect(config.requestComplexity).toEqual({ + includeDepth: 5, + includeCount: 50, + subqueryDepth: 5, + graphQLDepth: 50, + graphQLFields: 200, + }); + }); + }); + + describe('subquery depth', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 3 }, + }); + config = Config.get('test'); + }); + + it('should allow $inQuery within depth limit', async () => { + const where = buildNestedInQuery(3); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject $inQuery exceeding depth limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $notInQuery exceeding depth limit', async () => { + const where = buildNestedNotInQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $select exceeding depth limit', async () => { + const where = buildNestedSelect(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $dontSelect exceeding depth limit', async () => { + const where = buildNestedDontSelect(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow subqueries with master key even when exceeding limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.master(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow subqueries with maintenance key even when exceeding limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.maintenance(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow unlimited subqueries when subqueryDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: -1 }, + }); + config = Config.get('test'); + const where = buildNestedInQuery(15); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + }); + + describe('include limits', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3, includeCount: 5 }, + }); + config = Config.get('test'); + }); + + it('should allow include within depth limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c' }) + ).toBeResolved(); + }); + + it('should reject include exceeding depth limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d' }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Include depth of 4 exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow include count within limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e' }) + ).toBeResolved(); + }); + + it('should reject include count exceeding limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e,f' }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Number of include fields \(\d+\) exceeds maximum allowed \(5\)/), + }) + ); + }); + + it('should allow includeAll when within count limit', async () => { + const schema = new Parse.Schema('IncludeTestClass'); + schema.addPointer('ptr1', '_User'); + schema.addPointer('ptr2', '_User'); + schema.addPointer('ptr3', '_User'); + await schema.save(); + + const obj = new Parse.Object('IncludeTestClass'); + await obj.save(); + + await expectAsync( + rest.find(config, auth.nobody(config), 'IncludeTestClass', {}, { includeAll: true }) + ).toBeResolved(); + }); + + it('should reject includeAll when exceeding count limit', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3, includeCount: 2 }, + }); + config = Config.get('test'); + + const schema = new Parse.Schema('IncludeTestClass2'); + schema.addPointer('ptr1', '_User'); + schema.addPointer('ptr2', '_User'); + schema.addPointer('ptr3', '_User'); + await schema.save(); + + const obj = new Parse.Object('IncludeTestClass2'); + await obj.save(); + + await expectAsync( + rest.find(config, auth.nobody(config), 'IncludeTestClass2', {}, { includeAll: true }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Number of include fields .* exceeds maximum allowed/), + }) + ); + }); + + it('should allow includes with master key even when exceeding limits', async () => { + await expectAsync( + rest.find(config, auth.master(config), '_User', {}, { include: 'a.b.c.d' }) + ).toBeResolved(); + }); + + it('should allow unlimited depth when includeDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: -1 }, + }); + config = Config.get('test'); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d.e.f.g' }) + ).toBeResolved(); + }); + + it('should allow unlimited count when includeCount is -1', async () => { + await reconfigureServer({ + requestComplexity: { includeCount: -1 }, + }); + config = Config.get('test'); + const includes = Array.from({ length: 100 }, (_, i) => `field${i}`).join(','); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: includes }) + ).toBeResolved(); + }); + }); +}); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index fb5370d759..9418f856fd 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -390,6 +390,7 @@ describe('rest query', () => { }); it('battle test parallel include with 100 nested includes', async () => { + await reconfigureServer({ requestComplexity: { includeCount: 200 } }); const RootObject = Parse.Object.extend('RootObject'); const Level1Object = Parse.Object.extend('Level1Object'); const Level2Object = Parse.Object.extend('Level2Object'); diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index aea4468da8..7983e8b030 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -43,6 +43,7 @@ describe('Security Check Groups', () => { expect(group.checks()[2].checkState()).toBe(CheckState.success); expect(group.checks()[4].checkState()).toBe(CheckState.success); expect(group.checks()[5].checkState()).toBe(CheckState.success); + expect(group.checks()[7].checkState()).toBe(CheckState.success); }); it('checks fail correctly', async () => { @@ -50,6 +51,13 @@ describe('Security Check Groups', () => { config.security.enableCheckLog = true; config.allowClientClassCreation = true; config.graphQLPublicIntrospection = true; + config.requestComplexity = { + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, + graphQLDepth: -1, + graphQLFields: -1, + }; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -59,6 +67,7 @@ describe('Security Check Groups', () => { expect(group.checks()[2].checkState()).toBe(CheckState.fail); expect(group.checks()[4].checkState()).toBe(CheckState.fail); expect(group.checks()[5].checkState()).toBe(CheckState.fail); + expect(group.checks()[7].checkState()).toBe(CheckState.fail); }); it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => { diff --git a/src/Config.js b/src/Config.js index 241edf9771..766c3c59d7 100644 --- a/src/Config.js +++ b/src/Config.js @@ -16,6 +16,7 @@ import { LogLevels, PagesOptions, ParseServerOptions, + RequestComplexityOptions, SchemaOptions, SecurityOptions, } from './Options/Definitions'; @@ -129,6 +130,7 @@ export class Config { allowExpiredAuthDataToken, logLevels, rateLimit, + requestComplexity, databaseOptions, extendSessionOnUse, allowClientClassCreation, @@ -169,6 +171,7 @@ export class Config { this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateRequestKeywordDenylist(requestKeywordDenylist); this.validateRateLimit(rateLimit); + this.validateRequestComplexity(requestComplexity); this.validateLogLevels(logLevels); this.validateDatabaseOptions(databaseOptions); this.validateCustomPages(customPages); @@ -713,6 +716,31 @@ export class Config { } } + static validateRequestComplexity(requestComplexity) { + if (requestComplexity == null) { + return; + } + if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) { + throw new Error('requestComplexity must be an object.'); + } + const validKeys = Object.keys(RequestComplexityOptions); + for (const key of Object.keys(requestComplexity)) { + if (!validKeys.includes(key)) { + throw new Error(`requestComplexity contains unknown property '${key}'.`); + } + } + for (const key of validKeys) { + if (requestComplexity[key] !== undefined) { + const value = requestComplexity[key]; + if (!Number.isInteger(value) || (value < 1 && value !== -1)) { + throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`); + } + } else { + requestComplexity[key] = RequestComplexityOptions[key].default; + } + } + } + generateEmailVerifyTokenExpiresAt() { if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) { return undefined; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 231e44f5ef..bf4848e654 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -8,6 +8,7 @@ import { execute, subscribe, GraphQLError } from 'graphql'; import { SubscriptionServer } from 'subscriptions-transport-ws'; import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; import requiredParameter from '../requiredParameter'; +import { createComplexityValidationPlugin } from './helpers/queryComplexity'; import defaultLogger from '../logger'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; @@ -113,7 +114,7 @@ class ParseGraphQLServer { requestHeaders: ['X-Parse-Application-Id'], }, introspection: this.config.graphQLPublicIntrospection, - plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)], + plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)], schema, }); await apollo.start(); diff --git a/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js new file mode 100644 index 0000000000..0057e6438a --- /dev/null +++ b/src/GraphQL/helpers/queryComplexity.js @@ -0,0 +1,99 @@ +import { GraphQLError } from 'graphql'; +import logger from '../../logger'; + +function calculateQueryComplexity(operation, fragments) { + let maxDepth = 0; + let totalFields = 0; + + function visitSelectionSet(selectionSet, depth, visitedFragments) { + if (!selectionSet) { + return; + } + for (const selection of selectionSet.selections) { + if (selection.kind === 'Field') { + totalFields++; + const newDepth = depth + 1; + if (newDepth > maxDepth) { + maxDepth = newDepth; + } + if (selection.selectionSet) { + visitSelectionSet(selection.selectionSet, newDepth, visitedFragments); + } + } else if (selection.kind === 'InlineFragment') { + visitSelectionSet(selection.selectionSet, depth, visitedFragments); + } else if (selection.kind === 'FragmentSpread') { + const name = selection.name.value; + if (visitedFragments.has(name)) { + continue; + } + const fragment = fragments[name]; + if (fragment) { + const branchVisited = new Set(visitedFragments); + branchVisited.add(name); + visitSelectionSet(fragment.selectionSet, depth, branchVisited); + } + } + } + } + + visitSelectionSet(operation.selectionSet, 0, new Set()); + + return { depth: maxDepth, fields: totalFields }; +} + +function createComplexityValidationPlugin(getConfig) { + return { + requestDidStart: (requestContext) => ({ + didResolveOperation: async () => { + const auth = requestContext.contextValue?.auth; + if (auth?.isMaster || auth?.isMaintenance) { + return; + } + + const config = getConfig(); + if (!config) { + return; + } + + const { graphQLDepth, graphQLFields } = config; + if (graphQLDepth === -1 && graphQLFields === -1) { + return; + } + + const fragments = {}; + for (const definition of requestContext.document.definitions) { + if (definition.kind === 'FragmentDefinition') { + fragments[definition.name.value] = definition; + } + } + + const { depth, fields } = calculateQueryComplexity( + requestContext.operation, + fragments + ); + + if (graphQLDepth !== -1 && depth > graphQLDepth) { + const message = `GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`; + logger.warn(message); + throw new GraphQLError(message, { + extensions: { + http: { status: 400 }, + }, + }); + } + + if (graphQLFields !== -1 && fields > graphQLFields) { + const message = `Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`; + logger.warn(message); + throw new GraphQLError(message, { + extensions: { + http: { status: 400 }, + }, + }); + } + }, + }), + }; +} + +export { calculateQueryComplexity, createComplexityValidationPlugin }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 8e8581bd8d..43e908e8ab 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -524,6 +524,14 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', help: 'Read-only key, which has the same capabilities as MasterKey without writes', }, + requestComplexity: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY', + help: + 'Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.', + action: parsers.objectParser, + type: 'RequestComplexityOptions', + default: {}, + }, requestContextMiddleware: { env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', help: @@ -702,6 +710,42 @@ module.exports.RateLimitOptions = { default: 'ip', }, }; +module.exports.RequestComplexityOptions = { + graphQLDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH', + help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.', + action: parsers.numberParser('graphQLDepth'), + default: 50, + }, + graphQLFields: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS', + help: + 'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.', + action: parsers.numberParser('graphQLFields'), + default: 200, + }, + includeCount: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT', + help: + 'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.', + action: parsers.numberParser('includeCount'), + default: 50, + }, + includeDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH', + help: + 'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.', + action: parsers.numberParser('includeDepth'), + default: 5, + }, + subqueryDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH', + help: + 'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.', + action: parsers.numberParser('subqueryDepth'), + default: 5, + }, +}; module.exports.SecurityOptions = { checkGroups: { env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', diff --git a/src/Options/docs.js b/src/Options/docs.js index de9cef9076..d0235dad05 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -92,6 +92,7 @@ * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes + * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable. * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls @@ -126,6 +127,15 @@ * @property {String} zone The type of rate limit to apply. The following types are supported:Default is `ip`. */ +/** + * @interface RequestComplexityOptions + * @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`. + * @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`. + * @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`. + * @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`. + * @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. + */ + /** * @interface SecurityOptions * @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`. diff --git a/src/Options/index.js b/src/Options/index.js index 6468d63680..4de31122d7 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -330,6 +330,10 @@ export interface ParseServerOptions { schema: ?SchemaOptions; /* Callback when server has closed */ serverCloseComplete: ?() => void; + /* Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY + :DEFAULT: {} */ + requestComplexity: ?RequestComplexityOptions; /* The security options to identify and report weak security settings. :DEFAULT: {} */ security: ?SecurityOptions; @@ -385,6 +389,26 @@ export interface RateLimitOptions { zone: ?string; } +export interface RequestComplexityOptions { + /* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`. + :DEFAULT: 5 */ + includeDepth: ?number; + /* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`. + :DEFAULT: 50 */ + includeCount: ?number; + /* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. + :DEFAULT: 5 */ + subqueryDepth: ?number; + /* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH + :DEFAULT: 50 */ + graphQLDepth: ?number; + /* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS + :DEFAULT: 200 */ + graphQLFields: ?number; +} + export interface SecurityOptions { /* Is true if Parse Server should check for weak security settings. :DEFAULT: false */ diff --git a/src/RestQuery.js b/src/RestQuery.js index 2064ffd0df..4983669309 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -3,6 +3,7 @@ var SchemaController = require('./Controllers/SchemaController'); var Parse = require('parse/node').Parse; +var logger = require('./logger').default; const triggers = require('./triggers'); const { continueWhile } = require('parse/lib/node/promiseUtils'); const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL']; @@ -289,6 +290,9 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.handleIncludeAll(); }) + .then(() => { + return this.validateIncludeComplexity(); + }) .then(() => { return this.handleExcludeKeys(); }) @@ -359,6 +363,9 @@ _UnsafeRestQuery.prototype.buildRestWhere = function () { .then(() => { return this.validateClientClassCreation(); }) + .then(() => { + return this.checkSubqueryDepth(); + }) .then(() => { return this.replaceSelect(); }) @@ -451,6 +458,22 @@ function transformInQuery(inQueryObject, className, results) { } } +_UnsafeRestQuery.prototype.checkSubqueryDepth = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc || rc.subqueryDepth === -1) { + return; + } + const depth = this.context._subqueryDepth || 0; + if (depth > rc.subqueryDepth) { + const message = `Subquery nesting depth exceeds maximum allowed depth of ${rc.subqueryDepth}`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } +}; + // Replaces a $inQuery clause by running the subquery, if there is an // $inQuery clause. // The $inQuery clause turns into an $in with values that are just @@ -478,6 +501,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -485,7 +509,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () { className: inQueryValue.className, restWhere: inQueryValue.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { transformInQuery(inQueryObject, subquery.className, response.results); @@ -538,6 +562,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -545,7 +570,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () { className: notInQueryValue.className, restWhere: notInQueryValue.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { @@ -611,6 +636,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -618,7 +644,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () { className: selectValue.query.className, restWhere: selectValue.query.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { @@ -674,6 +700,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -681,7 +708,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () { className: dontSelectValue.query.className, restWhere: dontSelectValue.query.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { @@ -840,6 +867,29 @@ _UnsafeRestQuery.prototype.handleIncludeAll = function () { }); }; +_UnsafeRestQuery.prototype.validateIncludeComplexity = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc) { + return; + } + if (rc.includeDepth !== -1 && this.include && this.include.length > 0) { + const maxDepth = Math.max(...this.include.map(path => path.length)); + if (maxDepth > rc.includeDepth) { + const message = `Include depth of ${maxDepth} exceeds maximum allowed depth of ${rc.includeDepth}`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } + } + if (rc.includeCount !== -1 && this.include && this.include.length > rc.includeCount) { + const message = `Number of include fields (${this.include.length}) exceeds maximum allowed (${rc.includeCount})`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } +}; + // Updates property `this.keys` to contain all keys but the ones unselected. _UnsafeRestQuery.prototype.handleExcludeKeys = function () { if (!this.excludeKeys) { diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index 45e0b5ee81..60ec5a2aa3 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -105,6 +105,23 @@ class CheckGroupServerConfig extends CheckGroup { } }, }), + new Check({ + title: 'Request complexity limits enabled', + warning: + 'One or more request complexity limits are disabled, which may allow denial-of-service attacks through deeply nested or excessively broad queries.', + solution: + "Ensure all properties in 'requestComplexity' are set to positive integers. Set to '-1' only if you have other mitigations in place.", + check: () => { + const rc = config.requestComplexity; + if (!rc) { + throw 1; + } + const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.graphQLDepth, rc.graphQLFields]; + if (values.some(v => v === -1)) { + throw 1; + } + }, + }), new Check({ title: 'LiveQuery regex timeout enabled', warning: