From 278808d621d77d8057d8f7212d70bcb8483c2fd8 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 9 Nov 2025 00:53:41 +0100 Subject: [PATCH 1/6] feat: max depth and max fields protection system --- spec/ParseGraphQLQueryComplexity.spec.js | 569 ++++++++++++++++++++ spec/RestQuery.spec.js | 648 +++++++++++++++++++++++ src/Config.js | 14 + src/GraphQL/ParseGraphQLServer.js | 13 +- src/GraphQL/helpers/queryComplexity.js | 107 ++++ src/Options/Definitions.js | 12 + src/Options/docs.js | 2 + src/Options/index.js | 20 + src/RestQuery.js | 44 ++ 9 files changed, 1428 insertions(+), 1 deletion(-) create mode 100644 spec/ParseGraphQLQueryComplexity.spec.js create mode 100644 src/GraphQL/helpers/queryComplexity.js diff --git a/spec/ParseGraphQLQueryComplexity.spec.js b/spec/ParseGraphQLQueryComplexity.spec.js new file mode 100644 index 0000000000..17abb4f2dd --- /dev/null +++ b/spec/ParseGraphQLQueryComplexity.spec.js @@ -0,0 +1,569 @@ +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'); + +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: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)), + }); + + 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 createUserMutation = gql` + mutation { + createUser(input: { fields: { username: "testuser", password: "password123" } }) { + user { + objectId + username + createdAt + } + } + } + `; + + await apolloClient.mutate({ mutation: createUserMutation }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users.edges.length).toBeGreaterThan(0); + }); + + 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.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-Master-Key': 'test', + }, + }); + + const masterClient = new ApolloClient({ + link: httpLinkWithMaster, + cache: new InMemoryCache(), + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + updatedAt + sessionToken + } + } + } + } + `; + + 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: 3, + }, + }); + + // Create test data with relationships + const createClassMutation = gql` + mutation { + createClass(input: { name: "Post", schemaFields: { addStrings: [{ name: "title" }] } }) { + class { + name + } + } + } + `; + + await apolloClient.mutate({ + mutation: createClassMutation, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const query = gql` + query { + posts { + edges { + node { + objectId + title + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.posts).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.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-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-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: 5, + }, + }); + + const query = gql` + fragment UserFields on User { + objectId + username + createdAt + } + + query { + users { + edges { + node { + ...UserFields + } + } + } + } + `; + + 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 UserFields on User { + objectId + username + createdAt + updatedAt + } + + query { + users { + edges { + node { + ...UserFields + } + } + } + } + `; + + try { + await apolloClient.query({ query }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.message).toContain('Number of fields selected exceeds maximum allowed'); + } + }); + + it('should handle inline fragments correctly', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 5, + }, + }); + + 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 handle cyclic fragment references (GraphQL validation prevents actual cycles)', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 10, + }, + }); + + // Note: GraphQL's NoFragmentCycles validation rule prevents actual cycles + // This test verifies that our complexity calculation doesn't break when + // fragments reference each other (as long as there's no actual cycle) + const query = gql` + fragment UserBasicInfo on User { + objectId + username + } + + fragment UserDetailedInfo on User { + ...UserBasicInfo + createdAt + updatedAt + } + + query { + users { + edges { + node { + ...UserDetailedInfo + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + + it('should reject actual cyclic fragment definitions with GraphQL validation error', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 10, + }, + }); + + // This will fail at GraphQL parsing/validation level before our complexity check + // because GraphQL has built-in NoFragmentCycles rule + const queryString = ` + fragment FragmentA on User { + objectId + ...FragmentB + } + + fragment FragmentB on User { + username + ...FragmentA + } + + query { + users { + edges { + node { + ...FragmentA + } + } + } + } + `; + + try { + // Try to parse the query with cyclic fragments + const query = gql(queryString); + await apolloClient.query({ query }); + fail('Should have thrown an error due to cyclic fragments'); + } catch (error) { + // GraphQL validation should catch this before complexity calculation + expect(error.message).toMatch(/cycle|Cannot spread fragment/i); + } + }); + }); + + describe('Combined depth and fields validation', () => { + it('should validate both depth and fields limits', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + depth: 3, + fields: 5, + }, + }); + + 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.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 + sessionToken + authData + ACL + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + }); +}); + diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 6fe3c0fa18..aaa6323618 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -529,3 +529,651 @@ describe('RestQuery.each', () => { ]); }); }); + +describe('REST Query Complexity', () => { + beforeEach(async () => { + await reconfigureServer(); + }); + + describe('maxQueryComplexity.fields', () => { + it('should allow queries within fields limit', async () => { + await reconfigureServer({ + maxQueryComplexity: { + fields: 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({ + maxQueryComplexity: { + fields: 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 comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with include that exceeds limit (3 fields) + const query = new Parse.Query('Comment'); + query.include('post'); + query.include('post.author'); + + 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({ + maxQueryComplexity: { + fields: 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', + maxQueryComplexity: { + fields: 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('maxQueryComplexity.depth', () => { + it('should allow queries within depth limit', async () => { + await reconfigureServer({ + maxQueryComplexity: { + 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({ + maxQueryComplexity: { + 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({ + maxQueryComplexity: { + 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({ + maxQueryComplexity: { + 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', + maxQueryComplexity: { + 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({ + maxQueryComplexity: { + depth: 2, + fields: 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({ + maxQueryComplexity: { + depth: 10, // High depth limit + fields: 2, // Low fields 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 maxQueryComplexity.depth is configured', async () => { + await reconfigureServer({ + maxQueryComplexity: { + 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 maxQueryComplexity.fields is configured', async () => { + await reconfigureServer({ + maxQueryComplexity: { + fields: 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 maxQueryComplexity.depth is configured', async () => { + await reconfigureServer({ + maxQueryComplexity: { + 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 maxQueryComplexity.fields is configured', async () => { + await reconfigureServer({ + maxQueryComplexity: { + fields: 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({ + maxQueryComplexity: { + depth: 2, + fields: 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({ + maxQueryComplexity: { + 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({ + maxQueryComplexity: { + 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..072e49a5f0 100644 --- a/src/Config.js +++ b/src/Config.js @@ -132,6 +132,8 @@ export class Config { databaseOptions, extendSessionOnUse, allowClientClassCreation, + maxQueryComplexity, + 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(maxQueryComplexity, maxGraphQLQueryComplexity); } static validateCustomPages(customPages) { @@ -230,6 +233,17 @@ export class Config { } } + static validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity) { + if (maxQueryComplexity && maxGraphQLQueryComplexity) { + if (maxQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) { + throw new Error('maxQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth'); + } + if (maxQueryComplexity.fields >= maxGraphQLQueryComplexity.fields) { + throw new Error('maxQueryComplexity.fields must be less than maxGraphQLQueryComplexity.fields'); + } + } + } + static validateSecurityOptions(security) { if (Object.prototype.toString.call(security) !== '[object Object]') { throw 'Parse Server option security must be an object.'; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index bf7e14f7e2..020f84909d 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) => ({ @@ -98,6 +99,16 @@ class ParseGraphQLServer { return this._server; } const { schema, context } = await this._getGraphQLOptions(); + const plugins = [ + ApolloServerPluginCacheControlDisabled(), + IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), + ]; + + // Add complexity validation plugin if configured + if (this.parseServer.config.maxGraphQLQueryComplexity) { + plugins.push(createComplexityValidationPlugin(this.parseServer.config)); + } + const apollo = new ApolloServer({ csrfPrevention: { // See https://www.apollographql.com/docs/router/configuration/csrf/ @@ -105,7 +116,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..c7915c84a2 --- /dev/null +++ b/src/GraphQL/helpers/queryComplexity.js @@ -0,0 +1,107 @@ +import { GraphQLError, getOperationAST, Kind } from 'graphql'; + +/** + * Calculate the maximum depth and fields (field count) of a GraphQL query + * @param {DocumentNode} document - The GraphQL document AST + * @returns {{ depth: number, fields: number }} Maximum depth and total fields + */ +function calculateQueryComplexity(document) { + const operationAST = getOperationAST(document); + if (!operationAST || !operationAST.selectionSet) { + return { depth: 0, fields: 0 }; + } + + // Build fragment definition map + const fragments = {}; + if (document.definitions) { + document.definitions.forEach(def => { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + fragments[def.name.value] = def; + } + }); + } + + let maxDepth = 0; + let fields = 0; + + function visitSelectionSet(selectionSet, depth) { + if (!selectionSet || !selectionSet.selections) { + return; + } + + selectionSet.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) { + fields++; + maxDepth = Math.max(maxDepth, depth); + if (selection.selectionSet) { + visitSelectionSet(selection.selectionSet, depth + 1); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + // Inline fragments don't add depth, just traverse their selections + visitSelectionSet(selection.selectionSet, depth); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = selection.name.value; + const fragment = fragments[fragmentName]; + // Note: Circular fragments are already prevented by GraphQL validation (NoFragmentCycles rule) + // so we don't need to check for cycles here + if (fragment && fragment.selectionSet) { + visitSelectionSet(fragment.selectionSet, depth); + } + } + }); + } + + visitSelectionSet(operationAST.selectionSet, 1); + return { depth: maxDepth, fields }; +} + +/** + * Create a GraphQL complexity validation plugin for Apollo Server + * Computes depth and total field count directly from the parsed GraphQL document + * @param {Object} config - Parse Server config object + * @returns {Object} Apollo Server plugin + */ +export function createComplexityValidationPlugin(config) { + return { + requestDidStart: () => ({ + didResolveOperation: async (requestContext) => { + const { document } = requestContext; + const auth = requestContext.contextValue?.auth; + + // Skip validation for master/maintenance keys + if (auth?.isMaster || auth?.isMaintenance) { + return; + } + + // Skip if no complexity limits are configured + if (!config.maxGraphQLQueryComplexity) { + return; + } + + // Skip if document is not available + if (!document) { + return; + } + + const maxGraphQLQueryComplexity = config.maxGraphQLQueryComplexity; + + // Calculate depth and fields in a single pass for performance + const { depth, fields } = calculateQueryComplexity(document); + + // Validate fields (field count) + if (maxGraphQLQueryComplexity.fields && fields > maxGraphQLQueryComplexity.fields) { + throw new GraphQLError( + `Number of fields selected exceeds maximum allowed`, + ); + } + + // Validate maximum depth + if (maxGraphQLQueryComplexity.depth && depth > maxGraphQLQueryComplexity.depth) { + throw new GraphQLError( + `Query depth exceeds maximum allowed depth`, + ); + } + }, + }), + }; +} diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 1ae9512823..e540235bfe 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -396,6 +396,12 @@ module.exports.ParseServerOptions = { '(Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server.', action: parsers.numberParser('masterKeyTtl'), }, + maxGraphQLQueryComplexity: { + env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY', + help: + 'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + action: parsers.objectParser, + }, maxLimit: { env: 'PARSE_SERVER_MAX_LIMIT', help: 'Max value for limit option on queries, defaults to unlimited', @@ -407,6 +413,12 @@ module.exports.ParseServerOptions = { "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", action: parsers.numberOrStringParser('maxLogFiles'), }, + maxQueryComplexity: { + env: 'PARSE_SERVER_MAX_QUERY_COMPLEXITY', + help: + 'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + action: parsers.objectParser, + }, maxUploadSize: { env: 'PARSE_SERVER_MAX_UPLOAD_SIZE', help: 'Max file size for uploads, defaults to 20mb', diff --git a/src/Options/docs.js b/src/Options/docs.js index cdbd06de45..31e5770cc9 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -71,8 +71,10 @@ * @property {Union} masterKey Your Parse Master Key * @property {String[]} masterKeyIps (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. * @property {Number} masterKeyTtl (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. + * @property {QueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {Number} maxLimit Max value for limit option on queries, defaults to unlimited * @property {Number|String} maxLogFiles Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) + * @property {QueryComplexityOptions} maxQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {String} maxUploadSize Max file size for uploads, defaults to 20mb * @property {Union} middleware middleware for express server, can be string or function * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint diff --git a/src/Options/index.js b/src/Options/index.js index 81dbc3c536..215783c568 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -43,6 +43,10 @@ type RequestKeywordDenylist = { key: string | any, value: any, }; +type QueryComplexityOptions = { + depth: number, + fields: number, +}; export interface ParseServerOptions { /* Your Parse Application ID @@ -347,6 +351,22 @@ export interface ParseServerOptions { rateLimit: ?(RateLimitOptions[]); /* Options to customize the request context using inversion of control/dependency injection.*/ requestContextMiddleware: ?(req: any, res: any, next: any) => void; + /* Maximum query complexity for REST API includes. Controls depth and number of include fields. + * Format: { depth: number, fields: number } + * - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3) + * - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields) + * If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values + * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + */ + maxQueryComplexity: ?QueryComplexityOptions; + /* Maximum query complexity for GraphQL queries. Controls depth and number of operations. + * Format: { depth: number, fields: number } + * - depth: Maximum depth of nested field selections + * - fields: Maximum number of operations (queries/mutations) in a single request + * If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values + * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + */ + maxGraphQLQueryComplexity: ?QueryComplexityOptions; } export interface RateLimitOptions { diff --git a/src/RestQuery.js b/src/RestQuery.js index dd226f249c..48ddf9bab5 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -207,6 +207,18 @@ function _UnsafeRestQuery( this.doCount = true; break; case 'includeAll': + // Block includeAll if maxQueryComplexity is configured for non-master users + if ( + !this.auth.isMaster && + !this.auth.isMaintenance && + this.config.maxQueryComplexity && + (this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields) + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ); + } this.includeAll = true; break; case 'explain': @@ -236,6 +248,18 @@ function _UnsafeRestQuery( case 'include': { const paths = restOptions.include.split(','); if (paths.includes('*')) { + // Block includeAll if maxQueryComplexity is configured for non-master users + if ( + !this.auth.isMaster && + !this.auth.isMaintenance && + this.config.maxQueryComplexity && + (this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields) + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ); + } this.includeAll = true; break; } @@ -270,6 +294,26 @@ function _UnsafeRestQuery( throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad option: ' + option); } } + + // Validate query complexity for REST includes + if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxQueryComplexity && this.include && this.include.length > 0) { + const fieldsCount = this.include.length; + + if (this.config.maxQueryComplexity.fields && fieldsCount > this.config.maxQueryComplexity.fields) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Number of include fields exceeds maximum allowed` + ); + } + + const depth = Math.max(...this.include.map(path => path.length)); + if (this.config.maxQueryComplexity.depth && depth > this.config.maxQueryComplexity.depth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Include depth exceeds maximum allowed` + ); + } + } } // A convenient method to perform all the steps of processing a query From cfd31896ff6ed6056ea8c481ca911644fc09a0c7 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 9 Nov 2025 19:47:34 +0100 Subject: [PATCH 2/6] feat: fully working --- spec/ParseGraphQLQueryComplexity.spec.js | 124 ++++++++--------------- spec/RestQuery.spec.js | 72 +++++++------ src/Config.js | 16 +-- src/GraphQL/helpers/queryComplexity.js | 50 ++++++--- src/Options/Definitions.js | 8 +- src/Options/docs.js | 4 +- src/Options/index.js | 10 +- src/RestQuery.js | 20 ++-- 8 files changed, 146 insertions(+), 158 deletions(-) diff --git a/spec/ParseGraphQLQueryComplexity.spec.js b/spec/ParseGraphQLQueryComplexity.spec.js index 17abb4f2dd..b6c5088a52 100644 --- a/spec/ParseGraphQLQueryComplexity.spec.js +++ b/spec/ParseGraphQLQueryComplexity.spec.js @@ -4,6 +4,8 @@ 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; @@ -29,7 +31,11 @@ describe('ParseGraphQL Query Complexity', () => { const httpLink = createHttpLink({ uri: 'http://localhost:13378/graphql', - fetch: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)), + fetch, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, }); apolloClient = new ApolloClient({ @@ -57,20 +63,6 @@ describe('ParseGraphQL Query Complexity', () => { }, }); - const createUserMutation = gql` - mutation { - createUser(input: { fields: { username: "testuser", password: "password123" } }) { - user { - objectId - username - createdAt - } - } - } - `; - - await apolloClient.mutate({ mutation: createUserMutation }); - const query = gql` query { users { @@ -86,7 +78,7 @@ describe('ParseGraphQL Query Complexity', () => { `; const result = await apolloClient.query({ query }); - expect(result.data.users.edges.length).toBeGreaterThan(0); + expect(result.data.users).toBeDefined(); }); it('should reject queries exceeding fields limit', async () => { @@ -115,7 +107,7 @@ describe('ParseGraphQL Query Complexity', () => { await apolloClient.query({ query }); fail('Should have thrown an error'); } catch (error) { - expect(error.message).toContain('Number of fields selected exceeds maximum allowed'); + expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed'); } }); @@ -130,6 +122,7 @@ describe('ParseGraphQL Query Complexity', () => { 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', }, }); @@ -148,7 +141,7 @@ describe('ParseGraphQL Query Complexity', () => { username createdAt updatedAt - sessionToken + email } } } @@ -164,37 +157,17 @@ describe('ParseGraphQL Query Complexity', () => { it('should allow queries within depth limit', async () => { await reconfigureServer({ maxGraphQLQueryComplexity: { - depth: 3, - }, - }); - - // Create test data with relationships - const createClassMutation = gql` - mutation { - createClass(input: { name: "Post", schemaFields: { addStrings: [{ name: "title" }] } }) { - class { - name - } - } - } - `; - - await apolloClient.mutate({ - mutation: createClassMutation, - context: { - headers: { - 'X-Parse-Master-Key': 'test', - }, + depth: 4, }, }); const query = gql` query { - posts { + users { edges { node { objectId - title + username } } } @@ -202,7 +175,7 @@ describe('ParseGraphQL Query Complexity', () => { `; const result = await apolloClient.query({ query }); - expect(result.data.posts).toBeDefined(); + expect(result.data.users).toBeDefined(); }); it('should reject queries exceeding depth limit', async () => { @@ -229,7 +202,7 @@ describe('ParseGraphQL Query Complexity', () => { await apolloClient.query({ query }); fail('Should have thrown an error'); } catch (error) { - expect(error.message).toContain('Query depth exceeds maximum allowed depth'); + expect(error.networkError.result.errors[0].message).toContain('Query depth exceeds maximum allowed depth'); } }); @@ -244,6 +217,7 @@ describe('ParseGraphQL Query Complexity', () => { 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', }, }); @@ -283,6 +257,7 @@ describe('ParseGraphQL Query Complexity', () => { 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', }, }); @@ -315,12 +290,12 @@ describe('ParseGraphQL Query Complexity', () => { it('should count fields in fragments correctly', async () => { await reconfigureServer({ maxGraphQLQueryComplexity: { - fields: 5, + fields: 10, }, }); const query = gql` - fragment UserFields on User { + fragment UserFields1 on User { objectId username createdAt @@ -330,7 +305,7 @@ describe('ParseGraphQL Query Complexity', () => { users { edges { node { - ...UserFields + ...UserFields1 } } } @@ -349,7 +324,7 @@ describe('ParseGraphQL Query Complexity', () => { }); const query = gql` - fragment UserFields on User { + fragment UserFields2 on User { objectId username createdAt @@ -360,7 +335,7 @@ describe('ParseGraphQL Query Complexity', () => { users { edges { node { - ...UserFields + ...UserFields2 } } } @@ -371,14 +346,14 @@ describe('ParseGraphQL Query Complexity', () => { await apolloClient.query({ query }); fail('Should have thrown an error'); } catch (error) { - expect(error.message).toContain('Number of fields selected exceeds maximum allowed'); + 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: 5, + fields: 10, }, }); @@ -402,41 +377,36 @@ describe('ParseGraphQL Query Complexity', () => { expect(result.data.users).toBeDefined(); }); - it('should handle cyclic fragment references (GraphQL validation prevents actual cycles)', async () => { + it('should reject inline fragments exceeding fields limit', async () => { await reconfigureServer({ maxGraphQLQueryComplexity: { - fields: 10, + fields: 3, }, }); - // Note: GraphQL's NoFragmentCycles validation rule prevents actual cycles - // This test verifies that our complexity calculation doesn't break when - // fragments reference each other (as long as there's no actual cycle) const query = gql` - fragment UserBasicInfo on User { - objectId - username - } - - fragment UserDetailedInfo on User { - ...UserBasicInfo - createdAt - updatedAt - } - query { users { edges { node { - ...UserDetailedInfo + ... on User { + objectId + username + createdAt + updatedAt + } } } } } `; - const result = await apolloClient.query({ query }); - expect(result.data.users).toBeDefined(); + 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 () => { @@ -446,8 +416,6 @@ describe('ParseGraphQL Query Complexity', () => { }, }); - // This will fail at GraphQL parsing/validation level before our complexity check - // because GraphQL has built-in NoFragmentCycles rule const queryString = ` fragment FragmentA on User { objectId @@ -471,13 +439,11 @@ describe('ParseGraphQL Query Complexity', () => { `; try { - // Try to parse the query with cyclic fragments const query = gql(queryString); await apolloClient.query({ query }); fail('Should have thrown an error due to cyclic fragments'); } catch (error) { - // GraphQL validation should catch this before complexity calculation - expect(error.message).toMatch(/cycle|Cannot spread fragment/i); + expect(error.networkError?.result?.errors?.[0]?.message).toEqual('Cannot spread fragment "FragmentA" within itself via "FragmentB".'); } }); }); @@ -486,8 +452,8 @@ describe('ParseGraphQL Query Complexity', () => { it('should validate both depth and fields limits', async () => { await reconfigureServer({ maxGraphQLQueryComplexity: { - depth: 3, - fields: 5, + depth: 4, + fields: 10, }, }); @@ -534,7 +500,7 @@ describe('ParseGraphQL Query Complexity', () => { await apolloClient.query({ query }); fail('Should have thrown an error'); } catch (error) { - expect(error.message).toContain('Number of fields selected exceeds maximum allowed'); + expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed'); } }); }); @@ -552,9 +518,7 @@ describe('ParseGraphQL Query Complexity', () => { username createdAt updatedAt - sessionToken - authData - ACL + email } } } diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index aaa6323618..7b13f19a9a 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -535,11 +535,11 @@ describe('REST Query Complexity', () => { await reconfigureServer(); }); - describe('maxQueryComplexity.fields', () => { + describe('maxIncludeQueryComplexity.count', () => { it('should allow queries within fields limit', async () => { await reconfigureServer({ - maxQueryComplexity: { - fields: 5, + maxIncludeQueryComplexity: { + count: 5, }, }); @@ -571,8 +571,8 @@ describe('REST Query Complexity', () => { it('should reject queries exceeding fields limit', async () => { await reconfigureServer({ - maxQueryComplexity: { - fields: 2, + maxIncludeQueryComplexity: { + count: 2, }, }); @@ -587,15 +587,21 @@ describe('REST Query Complexity', () => { 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({ @@ -606,8 +612,8 @@ describe('REST Query Complexity', () => { it('should allow queries with master key even when exceeding fields limit', async () => { await reconfigureServer({ - maxQueryComplexity: { - fields: 2, + maxIncludeQueryComplexity: { + count: 2, }, }); @@ -639,8 +645,8 @@ describe('REST Query Complexity', () => { it('should allow queries with maintenance key even when exceeding fields limit', async () => { await reconfigureServer({ maintenanceKey: 'maintenanceKey456', - maxQueryComplexity: { - fields: 2, + maxIncludeQueryComplexity: { + count: 2, }, }); @@ -676,10 +682,10 @@ describe('REST Query Complexity', () => { }); }); - describe('maxQueryComplexity.depth', () => { + describe('maxIncludeQueryComplexity.depth', () => { it('should allow queries within depth limit', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 2, }, }); @@ -710,7 +716,7 @@ describe('REST Query Complexity', () => { it('should reject queries exceeding depth limit', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 1, }, }); @@ -744,7 +750,7 @@ describe('REST Query Complexity', () => { it('should calculate depth correctly for nested includes', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 3, }, }); @@ -781,7 +787,7 @@ describe('REST Query Complexity', () => { it('should allow queries with master key even when exceeding depth limit', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 1, }, }); @@ -813,7 +819,7 @@ describe('REST Query Complexity', () => { it('should allow queries with maintenance key even when exceeding depth limit', async () => { await reconfigureServer({ maintenanceKey: 'maintenanceKey789', - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 1, }, }); @@ -853,9 +859,9 @@ describe('REST Query Complexity', () => { describe('Combined depth and fields validation', () => { it('should validate both depth and fields limits', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 2, - fields: 3, + count: 3, }, }); @@ -885,9 +891,9 @@ describe('REST Query Complexity', () => { it('should reject if either depth or fields exceeds limit', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 10, // High depth limit - fields: 2, // Low fields limit + count: 2, // Low count limit }, }); @@ -927,9 +933,9 @@ describe('REST Query Complexity', () => { }); describe('includeAll blocking with query complexity limits', () => { - it('should block includeAll when maxQueryComplexity.depth is configured', async () => { + it('should block includeAll when maxIncludeQueryComplexity.depth is configured', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 2, }, }); @@ -957,10 +963,10 @@ describe('REST Query Complexity', () => { ); }); - it('should block includeAll when maxQueryComplexity.fields is configured', async () => { + it('should block includeAll when maxIncludeQueryComplexity.count is configured', async () => { await reconfigureServer({ - maxQueryComplexity: { - fields: 3, + maxIncludeQueryComplexity: { + count: 3, }, }); @@ -987,9 +993,9 @@ describe('REST Query Complexity', () => { ); }); - it('should block include("*") when maxQueryComplexity.depth is configured', async () => { + it('should block include("*") when maxIncludeQueryComplexity.depth is configured', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 2, }, }); @@ -1017,10 +1023,10 @@ describe('REST Query Complexity', () => { ); }); - it('should block include("*") when maxQueryComplexity.fields is configured', async () => { + it('should block include("*") when maxIncludeQueryComplexity.count is configured', async () => { await reconfigureServer({ - maxQueryComplexity: { - fields: 3, + maxIncludeQueryComplexity: { + count: 3, }, }); @@ -1049,9 +1055,9 @@ describe('REST Query Complexity', () => { it('should allow includeAll for master key requests', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 2, - fields: 3, + count: 3, }, }); @@ -1100,7 +1106,7 @@ describe('REST Query Complexity', () => { describe('Queries without includes', () => { it('should allow queries without includes regardless of complexity limits', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 1, paths: 1, }, @@ -1120,7 +1126,7 @@ describe('REST Query Complexity', () => { it('should allow queries with empty includes array', async () => { await reconfigureServer({ - maxQueryComplexity: { + maxIncludeQueryComplexity: { depth: 1, paths: 1, }, diff --git a/src/Config.js b/src/Config.js index 072e49a5f0..b8af265632 100644 --- a/src/Config.js +++ b/src/Config.js @@ -132,7 +132,7 @@ export class Config { databaseOptions, extendSessionOnUse, allowClientClassCreation, - maxQueryComplexity, + maxIncludeQueryComplexity, maxGraphQLQueryComplexity, }) { if (masterKey === readOnlyMasterKey) { @@ -175,7 +175,7 @@ export class Config { this.validateDatabaseOptions(databaseOptions); this.validateCustomPages(customPages); this.validateAllowClientClassCreation(allowClientClassCreation); - this.validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity); + this.validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity); } static validateCustomPages(customPages) { @@ -233,13 +233,13 @@ export class Config { } } - static validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity) { - if (maxQueryComplexity && maxGraphQLQueryComplexity) { - if (maxQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) { - throw new Error('maxQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth'); + static validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity) { + if (maxIncludeQueryComplexity && maxGraphQLQueryComplexity) { + if (maxIncludeQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) { + throw new Error('maxIncludeQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth'); } - if (maxQueryComplexity.fields >= maxGraphQLQueryComplexity.fields) { - throw new Error('maxQueryComplexity.fields must be less than maxGraphQLQueryComplexity.fields'); + if (maxIncludeQueryComplexity.count >= maxGraphQLQueryComplexity.fields) { + throw new Error('maxIncludeQueryComplexity.count must be less than maxGraphQLQueryComplexity.fields'); } } } diff --git a/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js index c7915c84a2..daa8af1908 100644 --- a/src/GraphQL/helpers/queryComplexity.js +++ b/src/GraphQL/helpers/queryComplexity.js @@ -3,9 +3,12 @@ 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 {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) { +function calculateQueryComplexity(document, maxLimits = {}) { const operationAST = getOperationAST(document); if (!operationAST || !operationAST.selectionSet) { return { depth: 0, fields: 0 }; @@ -33,6 +36,34 @@ function calculateQueryComplexity(document) { 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); } @@ -86,21 +117,8 @@ export function createComplexityValidationPlugin(config) { const maxGraphQLQueryComplexity = config.maxGraphQLQueryComplexity; // Calculate depth and fields in a single pass for performance - const { depth, fields } = calculateQueryComplexity(document); - - // Validate fields (field count) - if (maxGraphQLQueryComplexity.fields && fields > maxGraphQLQueryComplexity.fields) { - throw new GraphQLError( - `Number of fields selected exceeds maximum allowed`, - ); - } - - // Validate maximum depth - if (maxGraphQLQueryComplexity.depth && depth > maxGraphQLQueryComplexity.depth) { - throw new GraphQLError( - `Query depth exceeds maximum allowed depth`, - ); - } + // Pass max limits for early exit optimization - will throw immediately if exceeded + calculateQueryComplexity(document, maxGraphQLQueryComplexity); }, }), }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index e540235bfe..c81d4854ca 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -399,7 +399,7 @@ module.exports.ParseServerOptions = { maxGraphQLQueryComplexity: { env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY', help: - 'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + 'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', action: parsers.objectParser, }, maxLimit: { @@ -413,10 +413,10 @@ module.exports.ParseServerOptions = { "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", action: parsers.numberOrStringParser('maxLogFiles'), }, - maxQueryComplexity: { - env: 'PARSE_SERVER_MAX_QUERY_COMPLEXITY', + maxIncludeQueryComplexity: { + env: 'PARSE_SERVER_MAX_INCLUDE_QUERY_COMPLEXITY', help: - 'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + 'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', action: parsers.objectParser, }, maxUploadSize: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 31e5770cc9..bf42408872 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -71,10 +71,10 @@ * @property {Union} masterKey Your Parse Master Key * @property {String[]} masterKeyIps (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. * @property {Number} masterKeyTtl (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. - * @property {QueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + * @property {QueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {Number} maxLimit Max value for limit option on queries, defaults to unlimited * @property {Number|String} maxLogFiles Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) - * @property {QueryComplexityOptions} maxQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + * @property {QueryComplexityOptions} maxIncludeQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {String} maxUploadSize Max file size for uploads, defaults to 20mb * @property {Union} middleware middleware for express server, can be string or function * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint diff --git a/src/Options/index.js b/src/Options/index.js index 215783c568..aca773bf80 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -352,18 +352,18 @@ export interface ParseServerOptions { /* Options to customize the request context using inversion of control/dependency injection.*/ requestContextMiddleware: ?(req: any, res: any, next: any) => void; /* Maximum query complexity for REST API includes. Controls depth and number of include fields. - * Format: { depth: number, fields: number } + * Format: { depth: number, count: number } * - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3) - * - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields) - * If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values + * - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields) + * If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. */ - maxQueryComplexity: ?QueryComplexityOptions; + maxIncludeQueryComplexity: ?QueryComplexityOptions; /* Maximum query complexity for GraphQL queries. Controls depth and number of operations. * Format: { depth: number, fields: number } * - depth: Maximum depth of nested field selections * - fields: Maximum number of operations (queries/mutations) in a single request - * If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values + * If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. */ maxGraphQLQueryComplexity: ?QueryComplexityOptions; diff --git a/src/RestQuery.js b/src/RestQuery.js index 48ddf9bab5..a5f8cb24d5 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -207,12 +207,12 @@ function _UnsafeRestQuery( this.doCount = true; break; case 'includeAll': - // Block includeAll if maxQueryComplexity is configured for non-master users + // Block includeAll if maxIncludeQueryComplexity is configured for non-master users if ( !this.auth.isMaster && !this.auth.isMaintenance && - this.config.maxQueryComplexity && - (this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields) + this.config.maxIncludeQueryComplexity && + (this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count) ) { throw new Parse.Error( Parse.Error.INVALID_QUERY, @@ -248,12 +248,12 @@ function _UnsafeRestQuery( case 'include': { const paths = restOptions.include.split(','); if (paths.includes('*')) { - // Block includeAll if maxQueryComplexity is configured for non-master users + // Block includeAll if maxIncludeQueryComplexity is configured for non-master users if ( !this.auth.isMaster && !this.auth.isMaintenance && - this.config.maxQueryComplexity && - (this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields) + this.config.maxIncludeQueryComplexity && + (this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count) ) { throw new Parse.Error( Parse.Error.INVALID_QUERY, @@ -296,10 +296,10 @@ function _UnsafeRestQuery( } // Validate query complexity for REST includes - if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxQueryComplexity && this.include && this.include.length > 0) { - const fieldsCount = this.include.length; + if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxIncludeQueryComplexity && this.include && this.include.length > 0) { + const includeCount = this.include.length; - if (this.config.maxQueryComplexity.fields && fieldsCount > this.config.maxQueryComplexity.fields) { + if (this.config.maxIncludeQueryComplexity.count && includeCount > this.config.maxIncludeQueryComplexity.count) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Number of include fields exceeds maximum allowed` @@ -307,7 +307,7 @@ function _UnsafeRestQuery( } const depth = Math.max(...this.include.map(path => path.length)); - if (this.config.maxQueryComplexity.depth && depth > this.config.maxQueryComplexity.depth) { + if (this.config.maxIncludeQueryComplexity.depth && depth > this.config.maxIncludeQueryComplexity.depth) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Include depth exceeds maximum allowed` From 18ff763504d9104904c90ae7ca15fc5efe5ced8d Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 9 Nov 2025 19:59:06 +0100 Subject: [PATCH 3/6] fix: support multi document security --- spec/ParseGraphQLQueryComplexity.spec.js | 131 +++++++++++++++++++++++ src/GraphQL/helpers/queryComplexity.js | 10 +- 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/spec/ParseGraphQLQueryComplexity.spec.js b/spec/ParseGraphQLQueryComplexity.spec.js index b6c5088a52..f8b863b637 100644 --- a/spec/ParseGraphQLQueryComplexity.spec.js +++ b/spec/ParseGraphQLQueryComplexity.spec.js @@ -529,5 +529,136 @@ describe('ParseGraphQL Query Complexity', () => { 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/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js index daa8af1908..c45af10119 100644 --- a/src/GraphQL/helpers/queryComplexity.js +++ b/src/GraphQL/helpers/queryComplexity.js @@ -3,13 +3,14 @@ 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, maxLimits = {}) { - const operationAST = getOperationAST(document); +function calculateQueryComplexity(document, operationName, maxLimits = {}) { + const operationAST = getOperationAST(document, operationName); if (!operationAST || !operationAST.selectionSet) { return { depth: 0, fields: 0 }; } @@ -96,7 +97,7 @@ export function createComplexityValidationPlugin(config) { return { requestDidStart: () => ({ didResolveOperation: async (requestContext) => { - const { document } = requestContext; + const { document, operationName } = requestContext; const auth = requestContext.contextValue?.auth; // Skip validation for master/maintenance keys @@ -118,7 +119,8 @@ export function createComplexityValidationPlugin(config) { // Calculate depth and fields in a single pass for performance // Pass max limits for early exit optimization - will throw immediately if exceeded - calculateQueryComplexity(document, maxGraphQLQueryComplexity); + // SECURITY: operationName is crucial for multi-operation documents to validate the correct operation + calculateQueryComplexity(document, operationName, maxGraphQLQueryComplexity); }, }), }; From 6d59d8fd605d7800f2078d70802e24c5d8f6c3f7 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 9 Nov 2025 20:07:00 +0100 Subject: [PATCH 4/6] feat: update options name --- src/Options/docs.js | 4 +- src/Options/index.js | 10 +- types/Options/index.d.ts | 508 ++++++++++++++++++++------------------- 3 files changed, 269 insertions(+), 253 deletions(-) diff --git a/src/Options/docs.js b/src/Options/docs.js index bf42408872..fe91b2fb4e 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -71,10 +71,10 @@ * @property {Union} masterKey Your Parse Master Key * @property {String[]} masterKeyIps (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. * @property {Number} masterKeyTtl (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. - * @property {QueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + * @property {GraphQLQueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {Number} maxLimit Max value for limit option on queries, defaults to unlimited * @property {Number|String} maxLogFiles Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) - * @property {QueryComplexityOptions} maxIncludeQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + * @property {IncludeComplexityOptions} maxIncludeQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {String} maxUploadSize Max file size for uploads, defaults to 20mb * @property {Union} middleware middleware for express server, can be string or function * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint diff --git a/src/Options/index.js b/src/Options/index.js index aca773bf80..b1d7116b3d 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -43,10 +43,14 @@ type RequestKeywordDenylist = { key: string | any, value: any, }; -type QueryComplexityOptions = { +type GraphQLQueryComplexityOptions = { depth: number, fields: number, }; +type IncludeComplexityOptions = { + depth: number, + count: number, +}; export interface ParseServerOptions { /* Your Parse Application ID @@ -358,7 +362,7 @@ export interface ParseServerOptions { * If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. */ - maxIncludeQueryComplexity: ?QueryComplexityOptions; + maxIncludeQueryComplexity: ?IncludeComplexityOptions; /* Maximum query complexity for GraphQL queries. Controls depth and number of operations. * Format: { depth: number, fields: number } * - depth: Maximum depth of nested field selections @@ -366,7 +370,7 @@ export interface ParseServerOptions { * If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. */ - maxGraphQLQueryComplexity: ?QueryComplexityOptions; + maxGraphQLQueryComplexity: ?GraphQLQueryComplexityOptions; } export interface RateLimitOptions { diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index ad11050648..c357871ed6 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -9,13 +9,13 @@ import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter'; import { CheckGroup } from '../Security/CheckGroup'; export interface SchemaOptions { - definitions: any; - strict?: boolean; - deleteExtraFields?: boolean; - recreateModifiedFields?: boolean; - lockSchemas?: boolean; - beforeMigration?: () => void | Promise; - afterMigration?: () => void | Promise; + definitions: any; + strict?: boolean; + deleteExtraFields?: boolean; + recreateModifiedFields?: boolean; + lockSchemas?: boolean; + beforeMigration?: () => void | Promise; + afterMigration?: () => void | Promise; } type Adapter = string | T; type NumberOrBoolean = number | boolean; @@ -23,279 +23,291 @@ type NumberOrString = number | string; type ProtectedFields = any; type StringOrStringArray = string | string[]; type RequestKeywordDenylist = { - key: string; - value: any; + key: string; + value: any; +}; +type GraphQLQueryComplexityOptions = { + depth: number; + fields: number; +}; +type IncludeComplexityOptions = { + depth: number; + count: number; }; export interface ParseServerOptions { - appId: string; - masterKey: (() => void) | string; - masterKeyTtl?: number; - maintenanceKey: string; - serverURL: string; - masterKeyIps?: (string[]); - maintenanceKeyIps?: (string[]); - appName?: string; - allowHeaders?: (string[]); - allowOrigin?: StringOrStringArray; - analyticsAdapter?: Adapter; - filesAdapter?: Adapter; - push?: any; - scheduledPush?: boolean; - loggerAdapter?: Adapter; - jsonLogs?: boolean; - logsFolder?: string; - verbose?: boolean; - logLevel?: string; - logLevels?: LogLevels; - maxLogFiles?: NumberOrString; - silent?: boolean; - databaseURI: string; - databaseOptions?: DatabaseOptions; - databaseAdapter?: Adapter; - enableCollationCaseComparison?: boolean; - convertEmailToLowercase?: boolean; - convertUsernameToLowercase?: boolean; - cloud?: string; - collectionPrefix?: string; - clientKey?: string; - javascriptKey?: string; - dotNetKey?: string; - encryptionKey?: string; - restAPIKey?: string; - readOnlyMasterKey?: string; - webhookKey?: string; - fileKey?: string; - preserveFileName?: boolean; - userSensitiveFields?: (string[]); - protectedFields?: ProtectedFields; - enableAnonymousUsers?: boolean; - allowClientClassCreation?: boolean; - allowCustomObjectId?: boolean; - auth?: Record; - enableInsecureAuthAdapters?: boolean; - maxUploadSize?: string; - verifyUserEmails?: (boolean | void); - preventLoginWithUnverifiedEmail?: boolean; - preventSignupWithUnverifiedEmail?: boolean; - emailVerifyTokenValidityDuration?: number; - emailVerifyTokenReuseIfValid?: boolean; - sendUserEmailVerification?: (boolean | void); - accountLockout?: AccountLockoutOptions; - passwordPolicy?: PasswordPolicyOptions; - cacheAdapter?: Adapter; - emailAdapter?: Adapter; - encodeParseObjectInCloudFunction?: boolean; - publicServerURL?: string | (() => string) | (() => Promise); - pages?: PagesOptions; - customPages?: CustomPagesOptions; - liveQuery?: LiveQueryOptions; - sessionLength?: number; - extendSessionOnUse?: boolean; - defaultLimit?: number; - maxLimit?: number; - expireInactiveSessions?: boolean; - revokeSessionOnPasswordReset?: boolean; - cacheTTL?: number; - cacheMaxSize?: number; - directAccess?: boolean; - enableExpressErrorHandler?: boolean; - objectIdSize?: number; - port?: number; - host?: string; - mountPath?: string; - cluster?: NumberOrBoolean; - middleware?: ((() => void) | string); - trustProxy?: any; - startLiveQueryServer?: boolean; - liveQueryServerOptions?: LiveQueryServerOptions; - idempotencyOptions?: IdempotencyOptions; - fileUpload?: FileUploadOptions; - graphQLSchema?: string; - mountGraphQL?: boolean; - graphQLPath?: string; - mountPlayground?: boolean; - playgroundPath?: string; - schema?: SchemaOptions; - serverCloseComplete?: () => void; - security?: SecurityOptions; - enforcePrivateUsers?: boolean; - allowExpiredAuthDataToken?: boolean; - requestKeywordDenylist?: (RequestKeywordDenylist[]); - rateLimit?: (RateLimitOptions[]); - verifyServerUrl?: boolean; + appId: string; + masterKey: (() => void) | string; + masterKeyTtl?: number; + maintenanceKey: string; + serverURL: string; + masterKeyIps?: string[]; + maintenanceKeyIps?: string[]; + appName?: string; + allowHeaders?: string[]; + allowOrigin?: StringOrStringArray; + analyticsAdapter?: Adapter; + filesAdapter?: Adapter; + push?: any; + scheduledPush?: boolean; + loggerAdapter?: Adapter; + jsonLogs?: boolean; + logsFolder?: string; + verbose?: boolean; + logLevel?: string; + logLevels?: LogLevels; + maxLogFiles?: NumberOrString; + silent?: boolean; + databaseURI: string; + databaseOptions?: DatabaseOptions; + databaseAdapter?: Adapter; + enableCollationCaseComparison?: boolean; + convertEmailToLowercase?: boolean; + convertUsernameToLowercase?: boolean; + cloud?: string; + collectionPrefix?: string; + clientKey?: string; + javascriptKey?: string; + dotNetKey?: string; + encryptionKey?: string; + restAPIKey?: string; + readOnlyMasterKey?: string; + webhookKey?: string; + fileKey?: string; + preserveFileName?: boolean; + userSensitiveFields?: string[]; + protectedFields?: ProtectedFields; + enableAnonymousUsers?: boolean; + allowClientClassCreation?: boolean; + allowCustomObjectId?: boolean; + auth?: Record; + enableInsecureAuthAdapters?: boolean; + maxUploadSize?: string; + verifyUserEmails?: boolean | void; + preventLoginWithUnverifiedEmail?: boolean; + preventSignupWithUnverifiedEmail?: boolean; + emailVerifyTokenValidityDuration?: number; + emailVerifyTokenReuseIfValid?: boolean; + sendUserEmailVerification?: boolean | void; + accountLockout?: AccountLockoutOptions; + passwordPolicy?: PasswordPolicyOptions; + cacheAdapter?: Adapter; + emailAdapter?: Adapter; + encodeParseObjectInCloudFunction?: boolean; + publicServerURL?: string | (() => string) | (() => Promise); + pages?: PagesOptions; + customPages?: CustomPagesOptions; + liveQuery?: LiveQueryOptions; + sessionLength?: number; + extendSessionOnUse?: boolean; + defaultLimit?: number; + maxLimit?: number; + expireInactiveSessions?: boolean; + revokeSessionOnPasswordReset?: boolean; + cacheTTL?: number; + cacheMaxSize?: number; + directAccess?: boolean; + enableExpressErrorHandler?: boolean; + objectIdSize?: number; + port?: number; + host?: string; + mountPath?: string; + cluster?: NumberOrBoolean; + middleware?: (() => void) | string; + trustProxy?: any; + startLiveQueryServer?: boolean; + liveQueryServerOptions?: LiveQueryServerOptions; + idempotencyOptions?: IdempotencyOptions; + fileUpload?: FileUploadOptions; + graphQLSchema?: string; + mountGraphQL?: boolean; + graphQLPath?: string; + mountPlayground?: boolean; + playgroundPath?: string; + schema?: SchemaOptions; + serverCloseComplete?: () => void; + security?: SecurityOptions; + enforcePrivateUsers?: boolean; + allowExpiredAuthDataToken?: boolean; + requestKeywordDenylist?: RequestKeywordDenylist[]; + rateLimit?: RateLimitOptions[]; + verifyServerUrl?: boolean; + requestContextMiddleware?: (req: any, res: any, next: any) => void; + maxIncludeQueryComplexity?: IncludeComplexityOptions; + maxGraphQLQueryComplexity?: GraphQLQueryComplexityOptions; + graphQLPublicIntrospection?: boolean; } export interface RateLimitOptions { - requestPath: string; - requestTimeWindow?: number; - requestCount?: number; - errorResponseMessage?: string; - requestMethods?: (string[]); - includeMasterKey?: boolean; - includeInternalRequests?: boolean; - redisUrl?: string; - zone?: string; + requestPath: string; + requestTimeWindow?: number; + requestCount?: number; + errorResponseMessage?: string; + requestMethods?: string[]; + includeMasterKey?: boolean; + includeInternalRequests?: boolean; + redisUrl?: string; + zone?: string; } export interface SecurityOptions { - enableCheck?: boolean; - enableCheckLog?: boolean; - checkGroups?: (CheckGroup[]); + enableCheck?: boolean; + enableCheckLog?: boolean; + checkGroups?: CheckGroup[]; } export interface PagesOptions { - enableRouter?: boolean; - enableLocalization?: boolean; - localizationJsonPath?: string; - localizationFallbackLocale?: string; - placeholders?: any; - forceRedirect?: boolean; - pagesPath?: string; - pagesEndpoint?: string; - customUrls?: PagesCustomUrlsOptions; - customRoutes?: (PagesRoute[]); + enableRouter?: boolean; + enableLocalization?: boolean; + localizationJsonPath?: string; + localizationFallbackLocale?: string; + placeholders?: any; + forceRedirect?: boolean; + pagesPath?: string; + pagesEndpoint?: string; + customUrls?: PagesCustomUrlsOptions; + customRoutes?: PagesRoute[]; } export interface PagesRoute { - path: string; - method: string; - handler: () => void; + path: string; + method: string; + handler: () => void; } export interface PagesCustomUrlsOptions { - passwordReset?: string; - passwordResetLinkInvalid?: string; - passwordResetSuccess?: string; - emailVerificationSuccess?: string; - emailVerificationSendFail?: string; - emailVerificationSendSuccess?: string; - emailVerificationLinkInvalid?: string; - emailVerificationLinkExpired?: string; + passwordReset?: string; + passwordResetLinkInvalid?: string; + passwordResetSuccess?: string; + emailVerificationSuccess?: string; + emailVerificationSendFail?: string; + emailVerificationSendSuccess?: string; + emailVerificationLinkInvalid?: string; + emailVerificationLinkExpired?: string; } export interface CustomPagesOptions { - invalidLink?: string; - linkSendFail?: string; - choosePassword?: string; - linkSendSuccess?: string; - verifyEmailSuccess?: string; - passwordResetSuccess?: string; - invalidVerificationLink?: string; - expiredVerificationLink?: string; - invalidPasswordResetLink?: string; - parseFrameURL?: string; + invalidLink?: string; + linkSendFail?: string; + choosePassword?: string; + linkSendSuccess?: string; + verifyEmailSuccess?: string; + passwordResetSuccess?: string; + invalidVerificationLink?: string; + expiredVerificationLink?: string; + invalidPasswordResetLink?: string; + parseFrameURL?: string; } export interface LiveQueryOptions { - classNames?: (string[]); - redisOptions?: any; - redisURL?: string; - pubSubAdapter?: Adapter; - wssAdapter?: Adapter; + classNames?: string[]; + redisOptions?: any; + redisURL?: string; + pubSubAdapter?: Adapter; + wssAdapter?: Adapter; } export interface LiveQueryServerOptions { - appId?: string; - masterKey?: string; - serverURL?: string; - keyPairs?: any; - websocketTimeout?: number; - cacheTimeout?: number; - logLevel?: string; - port?: number; - redisOptions?: any; - redisURL?: string; - pubSubAdapter?: Adapter; - wssAdapter?: Adapter; + appId?: string; + masterKey?: string; + serverURL?: string; + keyPairs?: any; + websocketTimeout?: number; + cacheTimeout?: number; + logLevel?: string; + port?: number; + redisOptions?: any; + redisURL?: string; + pubSubAdapter?: Adapter; + wssAdapter?: Adapter; } export interface IdempotencyOptions { - paths?: (string[]); - ttl?: number; + paths?: string[]; + ttl?: number; } export interface AccountLockoutOptions { - duration?: number; - threshold?: number; - unlockOnPasswordReset?: boolean; + duration?: number; + threshold?: number; + unlockOnPasswordReset?: boolean; } export interface PasswordPolicyOptions { - validatorPattern?: string; - validatorCallback?: () => void; - validationError?: string; - doNotAllowUsername?: boolean; - maxPasswordAge?: number; - maxPasswordHistory?: number; - resetTokenValidityDuration?: number; - resetTokenReuseIfValid?: boolean; - resetPasswordSuccessOnInvalidEmail?: boolean; + validatorPattern?: string; + validatorCallback?: () => void; + validationError?: string; + doNotAllowUsername?: boolean; + maxPasswordAge?: number; + maxPasswordHistory?: number; + resetTokenValidityDuration?: number; + resetTokenReuseIfValid?: boolean; + resetPasswordSuccessOnInvalidEmail?: boolean; } export interface FileUploadOptions { - fileExtensions?: (string[]); - enableForAnonymousUser?: boolean; - enableForAuthenticatedUser?: boolean; - enableForPublic?: boolean; + fileExtensions?: string[]; + enableForAnonymousUser?: boolean; + enableForAuthenticatedUser?: boolean; + enableForPublic?: boolean; } export interface DatabaseOptions { - // Parse Server custom options - allowPublicExplain?: boolean; - createIndexRoleName?: boolean; - createIndexUserEmail?: boolean; - createIndexUserEmailCaseInsensitive?: boolean; - createIndexUserEmailVerifyToken?: boolean; - createIndexUserPasswordResetToken?: boolean; - createIndexUserUsername?: boolean; - createIndexUserUsernameCaseInsensitive?: boolean; - disableIndexFieldValidation?: boolean; - enableSchemaHooks?: boolean; - logClientEvents?: any[]; - // maxTimeMS is a MongoDB option but Parse Server applies it per-operation, not as a global client option - maxTimeMS?: number; - schemaCacheTtl?: number; + // Parse Server custom options + allowPublicExplain?: boolean; + createIndexRoleName?: boolean; + createIndexUserEmail?: boolean; + createIndexUserEmailCaseInsensitive?: boolean; + createIndexUserEmailVerifyToken?: boolean; + createIndexUserPasswordResetToken?: boolean; + createIndexUserUsername?: boolean; + createIndexUserUsernameCaseInsensitive?: boolean; + disableIndexFieldValidation?: boolean; + enableSchemaHooks?: boolean; + logClientEvents?: any[]; + // maxTimeMS is a MongoDB option but Parse Server applies it per-operation, not as a global client option + maxTimeMS?: number; + schemaCacheTtl?: number; - // MongoDB driver options - appName?: string; - authMechanism?: string; - authMechanismProperties?: any; - authSource?: string; - autoSelectFamily?: boolean; - autoSelectFamilyAttemptTimeout?: number; - compressors?: string[] | string; - connectTimeoutMS?: number; - directConnection?: boolean; - forceServerObjectId?: boolean; - heartbeatFrequencyMS?: number; - loadBalanced?: boolean; - localThresholdMS?: number; - maxConnecting?: number; - maxIdleTimeMS?: number; - maxPoolSize?: number; - maxStalenessSeconds?: number; - minPoolSize?: number; - proxyHost?: string; - proxyPassword?: string; - proxyPort?: number; - proxyUsername?: string; - readConcernLevel?: string; - readPreference?: string; - readPreferenceTags?: any[]; - replicaSet?: string; - retryReads?: boolean; - retryWrites?: boolean; - serverMonitoringMode?: string; - serverSelectionTimeoutMS?: number; - socketTimeoutMS?: number; - srvMaxHosts?: number; - srvServiceName?: string; - ssl?: boolean; - tls?: boolean; - tlsAllowInvalidCertificates?: boolean; - tlsAllowInvalidHostnames?: boolean; - tlsCAFile?: string; - tlsCertificateKeyFile?: string; - tlsCertificateKeyFilePassword?: string; - tlsInsecure?: boolean; - waitQueueTimeoutMS?: number; - zlibCompressionLevel?: number; + // MongoDB driver options + appName?: string; + authMechanism?: string; + authMechanismProperties?: any; + authSource?: string; + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + compressors?: string[] | string; + connectTimeoutMS?: number; + directConnection?: boolean; + forceServerObjectId?: boolean; + heartbeatFrequencyMS?: number; + loadBalanced?: boolean; + localThresholdMS?: number; + maxConnecting?: number; + maxIdleTimeMS?: number; + maxPoolSize?: number; + maxStalenessSeconds?: number; + minPoolSize?: number; + proxyHost?: string; + proxyPassword?: string; + proxyPort?: number; + proxyUsername?: string; + readConcernLevel?: string; + readPreference?: string; + readPreferenceTags?: any[]; + replicaSet?: string; + retryReads?: boolean; + retryWrites?: boolean; + serverMonitoringMode?: string; + serverSelectionTimeoutMS?: number; + socketTimeoutMS?: number; + srvMaxHosts?: number; + srvServiceName?: string; + ssl?: boolean; + tls?: boolean; + tlsAllowInvalidCertificates?: boolean; + tlsAllowInvalidHostnames?: boolean; + tlsCAFile?: string; + tlsCertificateKeyFile?: string; + tlsCertificateKeyFilePassword?: string; + tlsInsecure?: boolean; + waitQueueTimeoutMS?: number; + zlibCompressionLevel?: number; } export interface AuthAdapter { - enabled?: boolean; + enabled?: boolean; } export interface LogLevels { - triggerAfter?: string; - triggerBeforeSuccess?: string; - triggerBeforeError?: string; - cloudFunctionSuccess?: string; - cloudFunctionError?: string; + triggerAfter?: string; + triggerBeforeSuccess?: string; + triggerBeforeError?: string; + cloudFunctionSuccess?: string; + cloudFunctionError?: string; } export {}; From 1b6d5c7b0c55a158d5b7a922432149ac7473c6af Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 9 Nov 2025 20:12:09 +0100 Subject: [PATCH 5/6] fix: definitions --- src/Options/Definitions.js | 12 ++++++------ src/Options/docs.js | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index c81d4854ca..d910eabd8a 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -402,6 +402,12 @@ module.exports.ParseServerOptions = { 'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', action: parsers.objectParser, }, + maxIncludeQueryComplexity: { + env: 'PARSE_SERVER_MAX_INCLUDE_QUERY_COMPLEXITY', + help: + 'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + action: parsers.objectParser, + }, maxLimit: { env: 'PARSE_SERVER_MAX_LIMIT', help: 'Max value for limit option on queries, defaults to unlimited', @@ -413,12 +419,6 @@ module.exports.ParseServerOptions = { "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", action: parsers.numberOrStringParser('maxLogFiles'), }, - maxIncludeQueryComplexity: { - env: 'PARSE_SERVER_MAX_INCLUDE_QUERY_COMPLEXITY', - help: - 'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', - action: parsers.objectParser, - }, maxUploadSize: { env: 'PARSE_SERVER_MAX_UPLOAD_SIZE', help: 'Max file size for uploads, defaults to 20mb', diff --git a/src/Options/docs.js b/src/Options/docs.js index fe91b2fb4e..089b58008a 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -72,9 +72,9 @@ * @property {String[]} masterKeyIps (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. * @property {Number} masterKeyTtl (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. * @property {GraphQLQueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + * @property {IncludeComplexityOptions} maxIncludeQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {Number} maxLimit Max value for limit option on queries, defaults to unlimited * @property {Number|String} maxLogFiles Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) - * @property {IncludeComplexityOptions} maxIncludeQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {String} maxUploadSize Max file size for uploads, defaults to 20mb * @property {Union} middleware middleware for express server, can be string or function * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint From 5cc723f38ebdc83faa4885ed120dc2a046b6beb3 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 9 Nov 2025 20:21:54 +0100 Subject: [PATCH 6/6] fix: docs --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 12 ++++++------ types/Options/index.d.ts | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index d910eabd8a..5af320595f 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -399,7 +399,7 @@ module.exports.ParseServerOptions = { maxGraphQLQueryComplexity: { env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY', help: - 'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + 'Maximum query complexity for GraphQL queries. Controls depth and number of field selections.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of field selections in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', action: parsers.objectParser, }, maxIncludeQueryComplexity: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 089b58008a..eb6d9fcd42 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -71,7 +71,7 @@ * @property {Union} masterKey Your Parse Master Key * @property {String[]} masterKeyIps (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. * @property {Number} masterKeyTtl (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. - * @property {GraphQLQueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + * @property {GraphQLQueryComplexityOptions} maxGraphQLQueryComplexity Maximum query complexity for GraphQL queries. Controls depth and number of field selections.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of field selections in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {IncludeComplexityOptions} maxIncludeQueryComplexity Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. * @property {Number} maxLimit Max value for limit option on queries, defaults to unlimited * @property {Number|String} maxLogFiles Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) diff --git a/src/Options/index.js b/src/Options/index.js index b1d7116b3d..7d8ff4c1ca 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -44,12 +44,12 @@ type RequestKeywordDenylist = { value: any, }; type GraphQLQueryComplexityOptions = { - depth: number, - fields: number, + depth?: number, + fields?: number, }; type IncludeComplexityOptions = { - depth: number, - count: number, + depth?: number, + count?: number, }; export interface ParseServerOptions { @@ -363,10 +363,10 @@ export interface ParseServerOptions { * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. */ maxIncludeQueryComplexity: ?IncludeComplexityOptions; - /* Maximum query complexity for GraphQL queries. Controls depth and number of operations. + /* Maximum query complexity for GraphQL queries. Controls depth and number of field selections. * Format: { depth: number, fields: number } * - depth: Maximum depth of nested field selections - * - fields: Maximum number of operations (queries/mutations) in a single request + * - fields: Maximum number of field selections in a single request * If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. */ diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index c357871ed6..49b41e103e 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -27,12 +27,12 @@ type RequestKeywordDenylist = { value: any; }; type GraphQLQueryComplexityOptions = { - depth: number; - fields: number; + depth?: number; + fields?: number; }; type IncludeComplexityOptions = { - depth: number; - count: number; + depth?: number; + count?: number; }; export interface ParseServerOptions { appId: string;