diff --git a/spec/ParseGraphQLQueryComplexity.spec.js b/spec/ParseGraphQLQueryComplexity.spec.js
new file mode 100644
index 0000000000..1f31629bf9
--- /dev/null
+++ b/spec/ParseGraphQLQueryComplexity.spec.js
@@ -0,0 +1,1036 @@
+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();
+ });
+
+ it('should allow queries with maintenance key even when exceeding fields limit', async () => {
+ await reconfigureServer({
+ maintenanceKey: 'maintenanceKey123',
+ maxGraphQLQueryComplexity: {
+ fields: 3,
+ },
+ });
+
+ 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
+ updatedAt
+ email
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await maintenanceClient.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');
+ });
+ });
+
+ describe('Skipping validation with -1', () => {
+ it('should skip depth validation when depth is -1', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: -1,
+ fields: 50,
+ },
+ });
+
+ // Very deep query that would normally fail
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ updatedAt
+ email
+ emailVerified
+ username
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should skip fields validation when fields is -1', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 10,
+ fields: -1,
+ },
+ });
+
+ // Many fields query that would normally fail
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ updatedAt
+ email
+ emailVerified
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should skip both validations when both are -1', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: -1,
+ fields: -1,
+ },
+ });
+
+ // Very complex query
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ username
+ createdAt
+ updatedAt
+ email
+ emailVerified
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await apolloClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should enforce fields limit when depth is -1', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: -1,
+ 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');
+ }
+ });
+ });
+
+ describe('Restricting with depth 0', () => {
+ it('should reject all queries when depth is 0', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 0,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ }
+ }
+ }
+ }
+ `;
+
+ 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 reject all queries when fields is 0', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 0,
+ },
+ });
+
+ const query = gql`
+ query {
+ users {
+ edges {
+ node {
+ objectId
+ }
+ }
+ }
+ }
+ `;
+
+ 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 master key even with depth 0', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ depth: 0,
+ },
+ });
+
+ 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
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await masterClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should allow master key even with fields 0', async () => {
+ await reconfigureServer({
+ maxGraphQLQueryComplexity: {
+ fields: 0,
+ },
+ });
+
+ 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
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await masterClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should allow maintenance key even with depth 0', async () => {
+ await reconfigureServer({
+ maintenanceKey: 'maintenanceKey123',
+ maxGraphQLQueryComplexity: {
+ depth: 0,
+ },
+ });
+
+ 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
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await maintenanceClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+
+ it('should allow maintenance key even with fields 0', async () => {
+ await reconfigureServer({
+ maintenanceKey: 'maintenanceKey123',
+ maxGraphQLQueryComplexity: {
+ fields: 0,
+ },
+ });
+
+ 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
+ }
+ }
+ }
+ }
+ `;
+
+ const result = await maintenanceClient.query({ query });
+ expect(result.data.users).toBeDefined();
+ });
+ });
+});
+
diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js
index fb5370d759..4501c5720d 100644
--- a/spec/RestQuery.spec.js
+++ b/spec/RestQuery.spec.js
@@ -613,3 +613,1005 @@ 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);
+ });
+ });
+
+ describe('Skipping validation with -1', () => {
+ it('should skip depth validation when depth is -1', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: -1,
+ count: 2,
+ },
+ });
+
+ // Create test objects with deep relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_skip_depth');
+ 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();
+
+ // Deep query should work because depth is -1
+ 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 skip count validation when count is -1', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 1,
+ count: -1,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_skip_count');
+ 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();
+
+ // Many includes should work because count is -1
+ 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);
+ });
+
+ it('should skip both validations when both are -1', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: -1,
+ count: -1,
+ },
+ });
+
+ // Create test objects with very deep relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_skip_both');
+ 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();
+
+ // Very complex query should work
+ 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);
+ });
+
+ it('should allow includeAll when depth is -1', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: -1,
+ count: 5,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_depth_skip');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // includeAll should work because depth is -1
+ const query = new Parse.Query('Post');
+ query.includeAll();
+
+ const results = await query.find();
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow includeAll when count is -1', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 5,
+ count: -1,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_includeall_count_skip');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // includeAll should work because count is -1
+ const query = new Parse.Query('Post');
+ query.includeAll();
+
+ const results = await query.find();
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should enforce count limit when depth is -1', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: -1,
+ count: 1,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_enforce_count');
+ 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();
+
+ // Query with 2 includes should fail (count limit is 1)
+ const query = new Parse.Query('Post');
+ query.include('author');
+ query.include('category');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ jasmine.objectContaining({
+ code: Parse.Error.INVALID_QUERY,
+ })
+ );
+ });
+ });
+
+ describe('Restricting with depth 0 and count 0', () => {
+ it('should reject all includes when depth is 0', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 0,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_depth_zero');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Even simple include should be rejected
+ const query = new Parse.Query('Post');
+ query.include('author');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ jasmine.objectContaining({
+ code: Parse.Error.INVALID_QUERY,
+ })
+ );
+ });
+
+ it('should reject all includes when count is 0', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ count: 0,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_count_zero');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Even single include should be rejected
+ const query = new Parse.Query('Post');
+ query.include('author');
+
+ await expectAsync(query.find()).toBeRejectedWith(
+ jasmine.objectContaining({
+ code: Parse.Error.INVALID_QUERY,
+ })
+ );
+ });
+
+ it('should allow queries with depth 0 when no includes are present', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 0,
+ },
+ });
+
+ // Create simple objects without includes
+ const obj = new Parse.Object('SimpleObject');
+ obj.set('name', 'Test');
+ await obj.save();
+
+ // Query without includes should work
+ const query = new Parse.Query('SimpleObject');
+ const results = await query.find();
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow master key even with depth 0', async () => {
+ await reconfigureServer({
+ maxIncludeQueryComplexity: {
+ depth: 0,
+ },
+ });
+
+ // Create test objects with relationships
+ const user = new Parse.User();
+ user.setUsername('testuser_depth_zero_master');
+ 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 });
+
+ // Master key should bypass depth 0 restriction
+ const query = new Parse.Query('Post');
+ query.include('author');
+
+ const results = await query.find({ useMasterKey: true });
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('should allow maintenance key even with count 0', async () => {
+ await reconfigureServer({
+ maintenanceKey: 'maintenanceKeyZero',
+ maxIncludeQueryComplexity: {
+ count: 0,
+ },
+ });
+
+ // Create test objects with relationships using Parse SDK
+ const user = new Parse.User();
+ user.setUsername('testuser_count_zero_maint');
+ user.setPassword('password');
+ await user.signUp();
+
+ const post = new Parse.Object('Post');
+ post.set('title', 'Test Post');
+ post.set('author', user);
+ await post.save();
+
+ // Maintenance key should bypass count 0 restriction
+ const headers = {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Maintenance-Key': 'maintenanceKeyZero',
+ 'Content-Type': 'application/json',
+ };
+
+ const response = await request({
+ headers,
+ url: `http://localhost:8378/1/classes/Post?include=author`,
+ json: true,
+ });
+
+ expect(response.data.results.length).toBeGreaterThan(0);
+ });
+ });
+});
+
diff --git a/src/Config.js b/src/Config.js
index 241edf9771..4690c4d3af 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,26 @@ export class Config {
}
}
+ static validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity) {
+ if (maxIncludeQueryComplexity && maxGraphQLQueryComplexity) {
+ // Skip validation if either value is -1 (skip validation flag)
+ const includeDepth = maxIncludeQueryComplexity.depth;
+ const graphQLDepth = maxGraphQLQueryComplexity.depth;
+ const includeCount = maxIncludeQueryComplexity.count;
+ const graphQLFields = maxGraphQLQueryComplexity.fields;
+
+ // Validate depth only if neither is -1
+ if (includeDepth !== -1 && graphQLDepth !== -1 && includeDepth >= graphQLDepth) {
+ throw new Error('maxIncludeQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth');
+ }
+
+ // Validate count/fields only if neither is -1
+ if (includeCount !== -1 && graphQLFields !== -1 && includeCount >= graphQLFields) {
+ 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..7ba9c4a7ba
--- /dev/null
+++ b/src/GraphQL/helpers/queryComplexity.js
@@ -0,0 +1,141 @@
+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;
+
+ // Filter out -1 values (skip validation flag)
+ const maxLimits = {};
+ if (maxGraphQLQueryComplexity.depth !== -1 && maxGraphQLQueryComplexity.depth !== undefined) {
+ maxLimits.depth = maxGraphQLQueryComplexity.depth;
+ }
+ if (maxGraphQLQueryComplexity.fields !== -1 && maxGraphQLQueryComplexity.fields !== undefined) {
+ maxLimits.fields = maxGraphQLQueryComplexity.fields;
+ }
+
+ // Skip validation if all limits are -1
+ if (Object.keys(maxLimits).length === 0) {
+ return;
+ }
+
+ // 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, maxLimits);
+ },
+ }),
+ };
+}
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 66c1d8bcea..586b816107 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -404,6 +404,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 }`