diff --git a/spec/ParseGraphQLQueryComplexity.spec.js b/spec/ParseGraphQLQueryComplexity.spec.js
new file mode 100644
index 0000000000..f8b863b637
--- /dev/null
+++ b/spec/ParseGraphQLQueryComplexity.spec.js
@@ -0,0 +1,664 @@
+const http = require('http');
+const express = require('express');
+const gql = require('graphql-tag');
+const { ApolloClient, InMemoryCache, createHttpLink } = require('@apollo/client/core');
+const { ParseServer } = require('../');
+const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
+const Parse = require('parse/node');
+const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
+
+describe('ParseGraphQL Query Complexity', () => {
+ let parseServer;
+ let parseGraphQLServer;
+ let httpServer;
+ let apolloClient;
+
+ async function reconfigureServer(options = {}) {
+ if (httpServer) {
+ await httpServer.close();
+ }
+ parseServer = await global.reconfigureServer(options);
+ const expressApp = express();
+ httpServer = http.createServer(expressApp);
+ expressApp.use('/parse', parseServer.app);
+ parseGraphQLServer = new ParseGraphQLServer(parseServer, {
+ graphQLPath: '/graphql',
+ playgroundPath: '/playground',
+ subscriptionsPath: '/subscriptions',
+ });
+ parseGraphQLServer.applyGraphQL(expressApp);
+ await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
+
+ const httpLink = createHttpLink({
+ uri: 'http://localhost:13378/graphql',
+ fetch,
+ headers: {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Javascript-Key': 'test',
+ },
+ });
+
+ apolloClient = new ApolloClient({
+ link: httpLink,
+ cache: new InMemoryCache(),
+ defaultOptions: {
+ query: {
+ fetchPolicy: 'no-cache',
+ },
+ },
+ });
+ }
+
+ afterEach(async () => {
+ if (httpServer) {
+ await httpServer.close();
+ }
+ });
+
+ describe('maxGraphQLQueryComplexity.fields', () => {
+ it('should allow queries within fields limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 10,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should reject queries exceeding fields limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 3,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ updatedAt
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ await apolloClient.query({ query });
+ fail('Should have thrown an error');
+ } catch (error) {
+ expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed');
+ }
+ });
+
+ it('should allow queries with master key even when exceeding fields limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 3,
+ },
+ });
+
+ const httpLinkWithMaster = createHttpLink({
+ uri: 'http://localhost:13378/graphql',
+ fetch: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)),
+ headers: {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Master-Key': 'test',
+ },
+ });
+
+ const masterClient = new ApolloClient({
+ link: httpLinkWithMaster,
+ cache: new InMemoryCache(),
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ updatedAt
+ email
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await masterClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+ });
+
+ describe('maxGraphQLQueryComplexity.depth', () => {
+ it('should allow queries within depth limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 4,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should reject queries exceeding depth limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 2,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ await apolloClient.query({ query });
+ fail('Should have thrown an error');
+ } catch (error) {
+ expect(error.networkError.result.errors[0].message).toContain('Query depth exceeds maximum allowed depth');
+ }
+ });
+
+ it('should allow queries with master key even when exceeding depth limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 2,
+ },
+ });
+
+ const httpLinkWithMaster = createHttpLink({
+ uri: 'http://localhost:13378/graphql',
+ fetch: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)),
+ headers: {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Master-Key': 'test',
+ },
+ });
+
+ const masterClient = new ApolloClient({
+ link: httpLinkWithMaster,
+ cache: new InMemoryCache(),
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await masterClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should allow queries with maintenance key even when exceeding depth limit', async () => {
+ await reconfigureServer({
+ maintenanceKey: 'maintenanceKey123',
+ maxGraphQLQueryComplexity: {
+ depth: 2,
+ },
+ });
+
+ const httpLinkWithMaintenance = createHttpLink({
+ uri: 'http://localhost:13378/graphql',
+ fetch: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)),
+ headers: {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Maintenance-Key': 'maintenanceKey123',
+ },
+ });
+
+ const maintenanceClient = new ApolloClient({
+ link: httpLinkWithMaintenance,
+ cache: new InMemoryCache(),
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await maintenanceClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+ });
+
+ describe('Fragment handling', () => {
+ it('should count fields in fragments correctly', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 10,
+ },
+ });
+
+ const query = gql`
+ fragment UserFields1 on User {
+ objectId
+ username
+ createdAt
+ }
+
+ query {
+ users {
+ edges {
+ node {
+ ...UserFields1
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should reject queries with fragments exceeding fields limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 3,
+ },
+ });
+
+ const query = gql`
+ fragment UserFields2 on User {
+ objectId
+ username
+ createdAt
+ updatedAt
+ }
+
+ query {
+ users {
+ edges {
+ node {
+ ...UserFields2
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ await apolloClient.query({ query });
+ fail('Should have thrown an error');
+ } catch (error) {
+ expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed');
+ }
+ });
+
+ it('should handle inline fragments correctly', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 10,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ ... on User {
+ objectId
+ username
+ createdAt
+ }
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should reject inline fragments exceeding fields limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 3,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ ... on User {
+ objectId
+ username
+ createdAt
+ updatedAt
+ }
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ await apolloClient.query({ query });
+ fail('Should have thrown an error');
+ } catch (error) {
+ expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed');
+ }
+ });
+
+ it('should reject actual cyclic fragment definitions with GraphQL validation error', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 10,
+ },
+ });
+
+ const queryString = `
+ fragment FragmentA on User {
+ objectId
+ ...FragmentB
+ }
+
+ fragment FragmentB on User {
+ username
+ ...FragmentA
+ }
+
+ query {
+ users {
+ edges {
+ node {
+ ...FragmentA
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ const query = gql(queryString);
+ await apolloClient.query({ query });
+ fail('Should have thrown an error due to cyclic fragments');
+ } catch (error) {
+ expect(error.networkError?.result?.errors?.[0]?.message).toEqual('Cannot spread fragment "FragmentA" within itself via "FragmentB".');
+ }
+ });
+ });
+
+ describe('Combined depth and fields validation', () => {
+ it('should validate both depth and fields limits', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 4,
+ fields: 10,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should reject if either depth or fields exceeds limit', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 10,
+ fields: 2,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ await apolloClient.query({ query });
+ fail('Should have thrown an error');
+ } catch (error) {
+ expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed');
+ }
+ });
+ });
+
+ describe('No complexity limits configured', () => {
+ it('should allow complex queries when no limits are set', async () => {
+ await reconfigureServer({});
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ updatedAt
+ email
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+ });
+
+ describe('Multi-operation document handling (Security)', () => {
+ it('should validate the correct operation when multiple operations are in document', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 4,
+ },
+ });
+
+ // Document with two operations: one simple, one complex
+ const query = `
+ query SimpleQuery {
+ users {
+ edges {
+ node {
+ objectId
+ }
+ }
+ }
+ }
+
+ query ComplexQuery {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ updatedAt
+ email
+ }
+ }
+ }
+ }
+ `;
+
+ // SimpleQuery should pass (4 fields: users, edges, node, objectId)
+ const simpleResponse = await fetch('http://localhost:13378/graphql', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Javascript-Key': 'test',
+ },
+ body: JSON.stringify({
+ query,
+ operationName: 'SimpleQuery'
+ })
+ });
+ const simpleResult = await simpleResponse.json();
+ expect(simpleResult.data.users).toBeDefined();
+
+ // ComplexQuery should fail (8 fields > 4 limit)
+ const complexResponse = await fetch('http://localhost:13378/graphql', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Javascript-Key': 'test',
+ },
+ body: JSON.stringify({
+ query,
+ operationName: 'ComplexQuery'
+ })
+ });
+ const complexResult = await complexResponse.json();
+ expect(complexResult.errors).toBeDefined();
+ expect(complexResult.errors[0].message).toContain('Number of fields selected exceeds maximum allowed');
+ });
+
+ it('should block complex operation even when simple operation is first in document', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 2,
+ },
+ });
+
+ // First operation is simple (within limits), second is complex (exceeds limits)
+ const query = `
+ query ShallowQuery {
+ users {
+ count
+ }
+ }
+
+ query DeepQuery {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ }
+ }
+ }
+ }
+ `;
+
+ // ShallowQuery should pass (depth 2)
+ const shallowResponse = await fetch('http://localhost:13378/graphql', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Javascript-Key': 'test',
+ },
+ body: JSON.stringify({
+ query,
+ operationName: 'ShallowQuery'
+ })
+ });
+ const shallowResult = await shallowResponse.json();
+ expect(shallowResult.data.users).toBeDefined();
+
+ // DeepQuery should fail (depth 4 > 2 limit)
+ const deepResponse = await fetch('http://localhost:13378/graphql', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Javascript-Key': 'test',
+ },
+ body: JSON.stringify({
+ query,
+ operationName: 'DeepQuery'
+ })
+ });
+ const deepResult = await deepResponse.json();
+ expect(deepResult.errors).toBeDefined();
+ expect(deepResult.errors[0].message).toContain('Query depth exceeds maximum allowed depth');
+ });
+ });
+});
+
diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js
index fb5370d759..b5f25f66de 100644
--- a/spec/RestQuery.spec.js
+++ b/spec/RestQuery.spec.js
@@ -613,3 +613,657 @@ describe('RestQuery.each', () => {
]);
});
});
+
+describe('REST Query Complexity', () => {
+ beforeEach(async () => {
+ await reconfigureServer();
+ });
+
+ describe('maxIncludeQueryComplexity.count', () => {
+ it('should allow queries within fields limit', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ count: 5,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query with include that's within limit (3 fields: post -> author -> (2 levels))
+ const query = new Parse.Query('Comment');
+ query.include('post');
+ query.include('post.author');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0].get('post')).toBeDefined();
+ });
+
+ it('should reject queries exceeding fields limit', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ count: 2,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser2');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ const reply = new Parse.Object('Comment');
+ reply.set('text', 'Test Reply');
+ await reply.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ comment.set('reply', reply);
+ await comment.save();
+
+ // Query with include that exceeds limit (3 fields)
+ const query = new Parse.Query('Comment');
+ query.include('post');
+ query.include('post.author');
+ query.include('reply');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ jasmine.objectContaining({
+ code: Parse.Error.INVALID_QUERY,
+ })
+ );
+ });
+
+ it('should allow queries with master key even when exceeding fields limit', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ count: 2,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser3');
+ user.setPassword('password');
+ await user.signUp(null, { useMasterKey: true });
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save(null, { useMasterKey: true });
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save(null, { useMasterKey: true });
+
+ // Query with include that exceeds limit but using master key
+ const query = new Parse.Query('Comment');
+ query.include('post');
+ query.include('post.author');
+ const results = await query.find({ useMasterKey: true });
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow queries with maintenance key even when exceeding fields limit', async () => {
+ await reconfigureServer({
+ maintenanceKey: 'maintenanceKey456',
+ maxIncludeQueryComplexity: {
+ count: 2,
+ },
+ });
+
+ // Create test objects with relationships using Parse SDK
+ const user = new Parse.User();
+ user.setUsername('testuser4');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query with include that exceeds limit but using maintenance key via REST API
+ const headers = {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ 'X-Parse-Maintenance-Key': 'maintenanceKey456',
+ };
+ const response = await request({
+ headers,
+ url: `http://localhost:8378/1/classes/Comment?include=post,post.author`,
+ json: true,
+ });
+
+ expect(response.data.results.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('maxIncludeQueryComplexity.depth', () => {
+ it('should allow queries within depth limit', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 2,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser5');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query with include depth of 2 (post.author)
+ const query = new Parse.Query('Comment');
+ query.include('post.author');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should reject queries exceeding depth limit', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 1,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser6');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query with include depth of 2 (exceeds limit of 1)
+ const query = new Parse.Query('Comment');
+ query.include('post.author');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ jasmine.objectContaining({
+ code: Parse.Error.INVALID_QUERY,
+ })
+ );
+ });
+
+ it('should calculate depth correctly for nested includes', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 3,
+ },
+ });
+
+ // Create test objects with deep relationships
+ const user = new Parse.User();
+ user.setUsername('testuser7');
+ user.setPassword('password');
+ await user.signUp();
+
+ const category = new Parse.Object('Category');
+ category.set('name', 'Test Category');
+ await category.save();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ post.set('category', category);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query with include depth of 2 (post.author, post.category) - should be within limit
+ const query = new Parse.Query('Comment');
+ query.include('post.author');
+ query.include('post.category');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow queries with master key even when exceeding depth limit', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 1,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser7b');
+ user.setPassword('password');
+ await user.signUp(null, { useMasterKey: true });
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save(null, { useMasterKey: true });
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save(null, { useMasterKey: true });
+
+ // Query with include depth of 2 (exceeds limit of 1) but using master key
+ const query = new Parse.Query('Comment');
+ query.include('post.author');
+ const results = await query.find({ useMasterKey: true });
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow queries with maintenance key even when exceeding depth limit', async () => {
+ await reconfigureServer({
+ maintenanceKey: 'maintenanceKey789',
+ maxIncludeQueryComplexity: {
+ depth: 1,
+ },
+ });
+
+ // Create test objects with relationships using Parse SDK
+ const user = new Parse.User();
+ user.setUsername('testuser7c');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query with include depth of 2 (exceeds limit of 1) but using maintenance key via REST API
+ const headers = {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ 'X-Parse-Maintenance-Key': 'maintenanceKey789',
+ };
+ const response = await request({
+ headers,
+ url: `http://localhost:8378/1/classes/Comment?include=post.author`,
+ json: true,
+ });
+
+ expect(response.data.results.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Combined depth and fields validation', () => {
+ it('should validate both depth and fields limits', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 2,
+ count: 3,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser8');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query within both limits (1 field, depth 2)
+ const query = new Parse.Query('Comment');
+ query.include('post.author');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should reject if either depth or fields exceeds limit', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 10, // High depth limit
+ count: 2, // Low count limit
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser9');
+ user.setPassword('password');
+ await user.signUp();
+
+ const category = new Parse.Object('Category');
+ category.set('name', 'Test Category');
+ await category.save();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ post.set('category', category);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Query with 3 fields (exceeds fields limit) but within depth limit
+ const query = new Parse.Query('Comment');
+ query.include('post');
+ query.include('post.author');
+ query.include('post.category');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ jasmine.objectContaining({
+ code: Parse.Error.INVALID_QUERY,
+ })
+ );
+ });
+ });
+
+ describe('includeAll blocking with query complexity limits', () => {
+ it('should block includeAll when maxIncludeQueryComplexity.depth is configured', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 2,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_1');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Query with includeAll should be blocked
+ const query = new Parse.Query('Post');
+ query.includeAll();
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ new Parse.Error(
+ Parse.Error.INVALID_QUERY,
+ 'includeAll is not allowed when query complexity limits are configured'
+ )
+ );
+ });
+
+ it('should block includeAll when maxIncludeQueryComplexity.count is configured', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ count: 3,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_2');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Query with includeAll should be blocked
+ const query = new Parse.Query('Post');
+ query.includeAll();
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ new Parse.Error(
+ Parse.Error.INVALID_QUERY,
+ 'includeAll is not allowed when query complexity limits are configured'
+ )
+ );
+ });
+
+ it('should block include("*") when maxIncludeQueryComplexity.depth is configured', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 2,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_3');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Query with include("*") should be blocked
+ const query = new Parse.Query('Post');
+ query.include('*');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ new Parse.Error(
+ Parse.Error.INVALID_QUERY,
+ 'includeAll is not allowed when query complexity limits are configured'
+ )
+ );
+ });
+
+ it('should block include("*") when maxIncludeQueryComplexity.count is configured', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ count: 3,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_4');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Query with include("*") should be blocked
+ const query = new Parse.Query('Post');
+ query.include('*');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ new Parse.Error(
+ Parse.Error.INVALID_QUERY,
+ 'includeAll is not allowed when query complexity limits are configured'
+ )
+ );
+ });
+
+ it('should allow includeAll for master key requests', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 2,
+ count: 3,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_5');
+ user.setPassword('password');
+ await user.signUp(null, { useMasterKey: true });
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save(null, { useMasterKey: true });
+
+ // Query with includeAll should work with master key
+ const query = new Parse.Query('Post');
+ query.includeAll();
+
+ const results = await query.find({ useMasterKey: true });
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow includeAll when no complexity limits are configured', async () => {
+ await reconfigureServer({});
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_6');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Query with includeAll should work when no limits are configured
+ const query = new Parse.Query('Post');
+ query.includeAll();
+
+ const results = await query.find();
+ expect(results.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Queries without includes', () => {
+ it('should allow queries without includes regardless of complexity limits', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 1,
+ paths: 1,
+ },
+ });
+
+ const simpleObject = new Parse.Object('SimpleObject');
+ simpleObject.set('name', 'Test');
+ simpleObject.set('value', 123);
+ await simpleObject.save();
+
+ // Query without includes should not be affected by complexity limits
+ const query = new Parse.Query('SimpleObject');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow queries with empty includes array', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 1,
+ paths: 1,
+ },
+ });
+
+ const simpleObject = new Parse.Object('SimpleObject');
+ simpleObject.set('name', 'Test');
+ simpleObject.set('value', 123);
+ await simpleObject.save();
+
+ // Query with empty includes should not be affected
+ const query = new Parse.Query('SimpleObject');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('No complexity limits configured', () => {
+ it('should allow complex queries when no limits are set', async () => {
+ // Use default config without complexity limits
+ await reconfigureServer();
+
+ // Create test objects with deep relationships
+ const user = new Parse.User();
+ user.setUsername('testuser10');
+ user.setPassword('password');
+ await user.signUp();
+
+ const category = new Parse.Object('Category');
+ category.set('name', 'Test Category');
+ await category.save();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ post.set('category', category);
+ await post.save();
+
+ const comment = new Parse.Object('Comment');
+ comment.set('text', 'Test Comment');
+ comment.set('post', post);
+ await comment.save();
+
+ // Complex query should work without limits
+ const query = new Parse.Query('Comment');
+ query.include('post');
+ query.include('post.author');
+ query.include('post.category');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+ });
+});
+
diff --git a/src/Config.js b/src/Config.js
index 241edf9771..b8af265632 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -132,6 +132,8 @@ export class Config {
databaseOptions,
extendSessionOnUse,
allowClientClassCreation,
+ maxIncludeQueryComplexity,
+ maxGraphQLQueryComplexity,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -173,6 +175,7 @@ export class Config {
this.validateDatabaseOptions(databaseOptions);
this.validateCustomPages(customPages);
this.validateAllowClientClassCreation(allowClientClassCreation);
+ this.validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity);
}
static validateCustomPages(customPages) {
@@ -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.';
diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js
index 231e44f5ef..ca4e1a6774 100644
--- a/src/GraphQL/ParseGraphQLServer.js
+++ b/src/GraphQL/ParseGraphQLServer.js
@@ -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) => ({
@@ -106,6 +107,16 @@ class ParseGraphQLServer {
const createServer = async () => {
try {
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/
@@ -113,7 +124,7 @@ class ParseGraphQLServer {
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: this.config.graphQLPublicIntrospection,
- plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
+ plugins,
schema,
});
await apollo.start();
diff --git a/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js
new file mode 100644
index 0000000000..c45af10119
--- /dev/null
+++ b/src/GraphQL/helpers/queryComplexity.js
@@ -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);
+ },
+ }),
+ };
+}
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 6eeff0ed57..74892abe17 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -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 }`