diff --git a/spec/ParseGraphQLQueryComplexity.spec.js b/spec/ParseGraphQLQueryComplexity.spec.js new file mode 100644 index 0000000000..f8b863b637 --- /dev/null +++ b/spec/ParseGraphQLQueryComplexity.spec.js @@ -0,0 +1,664 @@ +const http = require('http'); +const express = require('express'); +const gql = require('graphql-tag'); +const { ApolloClient, InMemoryCache, createHttpLink } = require('@apollo/client/core'); +const { ParseServer } = require('../'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); +const Parse = require('parse/node'); +const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); + +describe('ParseGraphQL Query Complexity', () => { + let parseServer; + let parseGraphQLServer; + let httpServer; + let apolloClient; + + async function reconfigureServer(options = {}) { + if (httpServer) { + await httpServer.close(); + } + parseServer = await global.reconfigureServer(options); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', parseServer.app); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + subscriptionsPath: '/subscriptions', + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve)); + + const httpLink = createHttpLink({ + uri: 'http://localhost:13378/graphql', + fetch, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }); + + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + } + + afterEach(async () => { + if (httpServer) { + await httpServer.close(); + } + }); + + describe('maxGraphQLQueryComplexity.fields', () => { + it('should allow queries within fields limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 10, + }, + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + + it('should reject queries exceeding fields limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 3, + }, + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + updatedAt + } + } + } + } + `; + + try { + await apolloClient.query({ query }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed'); + } + }); + + it('should allow queries with master key even when exceeding fields limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 3, + }, + }); + + const httpLinkWithMaster = createHttpLink({ + uri: 'http://localhost:13378/graphql', + fetch: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + + const masterClient = new ApolloClient({ + link: httpLinkWithMaster, + cache: new InMemoryCache(), + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + updatedAt + email + } + } + } + } + `; + + const result = await masterClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + }); + + describe('maxGraphQLQueryComplexity.depth', () => { + it('should allow queries within depth limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + depth: 4, + }, + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + + it('should reject queries exceeding depth limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + depth: 2, + }, + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + } + } + } + } + `; + + try { + await apolloClient.query({ query }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.networkError.result.errors[0].message).toContain('Query depth exceeds maximum allowed depth'); + } + }); + + it('should allow queries with master key even when exceeding depth limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + depth: 2, + }, + }); + + const httpLinkWithMaster = createHttpLink({ + uri: 'http://localhost:13378/graphql', + fetch: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + + const masterClient = new ApolloClient({ + link: httpLinkWithMaster, + cache: new InMemoryCache(), + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + } + } + } + } + `; + + const result = await masterClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + + it('should allow queries with maintenance key even when exceeding depth limit', async () => { + await reconfigureServer({ + maintenanceKey: 'maintenanceKey123', + maxGraphQLQueryComplexity: { + depth: 2, + }, + }); + + const httpLinkWithMaintenance = createHttpLink({ + uri: 'http://localhost:13378/graphql', + fetch: (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Maintenance-Key': 'maintenanceKey123', + }, + }); + + const maintenanceClient = new ApolloClient({ + link: httpLinkWithMaintenance, + cache: new InMemoryCache(), + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + } + } + } + } + `; + + const result = await maintenanceClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + }); + + describe('Fragment handling', () => { + it('should count fields in fragments correctly', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 10, + }, + }); + + const query = gql` + fragment UserFields1 on User { + objectId + username + createdAt + } + + query { + users { + edges { + node { + ...UserFields1 + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + + it('should reject queries with fragments exceeding fields limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 3, + }, + }); + + const query = gql` + fragment UserFields2 on User { + objectId + username + createdAt + updatedAt + } + + query { + users { + edges { + node { + ...UserFields2 + } + } + } + } + `; + + try { + await apolloClient.query({ query }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed'); + } + }); + + it('should handle inline fragments correctly', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 10, + }, + }); + + const query = gql` + query { + users { + edges { + node { + ... on User { + objectId + username + createdAt + } + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + + it('should reject inline fragments exceeding fields limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 3, + }, + }); + + const query = gql` + query { + users { + edges { + node { + ... on User { + objectId + username + createdAt + updatedAt + } + } + } + } + } + `; + + try { + await apolloClient.query({ query }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed'); + } + }); + + it('should reject actual cyclic fragment definitions with GraphQL validation error', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 10, + }, + }); + + const queryString = ` + fragment FragmentA on User { + objectId + ...FragmentB + } + + fragment FragmentB on User { + username + ...FragmentA + } + + query { + users { + edges { + node { + ...FragmentA + } + } + } + } + `; + + try { + const query = gql(queryString); + await apolloClient.query({ query }); + fail('Should have thrown an error due to cyclic fragments'); + } catch (error) { + expect(error.networkError?.result?.errors?.[0]?.message).toEqual('Cannot spread fragment "FragmentA" within itself via "FragmentB".'); + } + }); + }); + + describe('Combined depth and fields validation', () => { + it('should validate both depth and fields limits', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + depth: 4, + fields: 10, + }, + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + + it('should reject if either depth or fields exceeds limit', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + depth: 10, + fields: 2, + }, + }); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + } + } + } + } + `; + + try { + await apolloClient.query({ query }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.networkError.result.errors[0].message).toContain('Number of fields selected exceeds maximum allowed'); + } + }); + }); + + describe('No complexity limits configured', () => { + it('should allow complex queries when no limits are set', async () => { + await reconfigureServer({}); + + const query = gql` + query { + users { + edges { + node { + objectId + username + createdAt + updatedAt + email + } + } + } + } + `; + + const result = await apolloClient.query({ query }); + expect(result.data.users).toBeDefined(); + }); + }); + + describe('Multi-operation document handling (Security)', () => { + it('should validate the correct operation when multiple operations are in document', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + fields: 4, + }, + }); + + // Document with two operations: one simple, one complex + const query = ` + query SimpleQuery { + users { + edges { + node { + objectId + } + } + } + } + + query ComplexQuery { + users { + edges { + node { + objectId + username + createdAt + updatedAt + email + } + } + } + } + `; + + // SimpleQuery should pass (4 fields: users, edges, node, objectId) + const simpleResponse = await fetch('http://localhost:13378/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + body: JSON.stringify({ + query, + operationName: 'SimpleQuery' + }) + }); + const simpleResult = await simpleResponse.json(); + expect(simpleResult.data.users).toBeDefined(); + + // ComplexQuery should fail (8 fields > 4 limit) + const complexResponse = await fetch('http://localhost:13378/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + body: JSON.stringify({ + query, + operationName: 'ComplexQuery' + }) + }); + const complexResult = await complexResponse.json(); + expect(complexResult.errors).toBeDefined(); + expect(complexResult.errors[0].message).toContain('Number of fields selected exceeds maximum allowed'); + }); + + it('should block complex operation even when simple operation is first in document', async () => { + await reconfigureServer({ + maxGraphQLQueryComplexity: { + depth: 2, + }, + }); + + // First operation is simple (within limits), second is complex (exceeds limits) + const query = ` + query ShallowQuery { + users { + count + } + } + + query DeepQuery { + users { + edges { + node { + objectId + username + } + } + } + } + `; + + // ShallowQuery should pass (depth 2) + const shallowResponse = await fetch('http://localhost:13378/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + body: JSON.stringify({ + query, + operationName: 'ShallowQuery' + }) + }); + const shallowResult = await shallowResponse.json(); + expect(shallowResult.data.users).toBeDefined(); + + // DeepQuery should fail (depth 4 > 2 limit) + const deepResponse = await fetch('http://localhost:13378/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + body: JSON.stringify({ + query, + operationName: 'DeepQuery' + }) + }); + const deepResult = await deepResponse.json(); + expect(deepResult.errors).toBeDefined(); + expect(deepResult.errors[0].message).toContain('Query depth exceeds maximum allowed depth'); + }); + }); +}); + diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 6fe3c0fa18..7b13f19a9a 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -529,3 +529,657 @@ describe('RestQuery.each', () => { ]); }); }); + +describe('REST Query Complexity', () => { + beforeEach(async () => { + await reconfigureServer(); + }); + + describe('maxIncludeQueryComplexity.count', () => { + it('should allow queries within fields limit', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + count: 5, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with include that's within limit (3 fields: post -> author -> (2 levels)) + const query = new Parse.Query('Comment'); + query.include('post'); + query.include('post.author'); + const results = await query.find(); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].get('post')).toBeDefined(); + }); + + it('should reject queries exceeding fields limit', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + count: 2, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser2'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + const reply = new Parse.Object('Comment'); + reply.set('text', 'Test Reply'); + await reply.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + comment.set('reply', reply); + await comment.save(); + + // Query with include that exceeds limit (3 fields) + const query = new Parse.Query('Comment'); + query.include('post'); + query.include('post.author'); + query.include('reply'); + + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + }) + ); + }); + + it('should allow queries with master key even when exceeding fields limit', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + count: 2, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser3'); + user.setPassword('password'); + await user.signUp(null, { useMasterKey: true }); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(null, { useMasterKey: true }); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(null, { useMasterKey: true }); + + // Query with include that exceeds limit but using master key + const query = new Parse.Query('Comment'); + query.include('post'); + query.include('post.author'); + const results = await query.find({ useMasterKey: true }); + + expect(results.length).toBeGreaterThan(0); + }); + + it('should allow queries with maintenance key even when exceeding fields limit', async () => { + await reconfigureServer({ + maintenanceKey: 'maintenanceKey456', + maxIncludeQueryComplexity: { + count: 2, + }, + }); + + // Create test objects with relationships using Parse SDK + const user = new Parse.User(); + user.setUsername('testuser4'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with include that exceeds limit but using maintenance key via REST API + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Maintenance-Key': 'maintenanceKey456', + }; + const response = await request({ + headers, + url: `http://localhost:8378/1/classes/Comment?include=post,post.author`, + json: true, + }); + + expect(response.data.results.length).toBeGreaterThan(0); + }); + }); + + describe('maxIncludeQueryComplexity.depth', () => { + it('should allow queries within depth limit', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 2, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser5'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with include depth of 2 (post.author) + const query = new Parse.Query('Comment'); + query.include('post.author'); + const results = await query.find(); + + expect(results.length).toBeGreaterThan(0); + }); + + it('should reject queries exceeding depth limit', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 1, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser6'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with include depth of 2 (exceeds limit of 1) + const query = new Parse.Query('Comment'); + query.include('post.author'); + + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + }) + ); + }); + + it('should calculate depth correctly for nested includes', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 3, + }, + }); + + // Create test objects with deep relationships + const user = new Parse.User(); + user.setUsername('testuser7'); + user.setPassword('password'); + await user.signUp(); + + const category = new Parse.Object('Category'); + category.set('name', 'Test Category'); + await category.save(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + post.set('category', category); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with include depth of 2 (post.author, post.category) - should be within limit + const query = new Parse.Query('Comment'); + query.include('post.author'); + query.include('post.category'); + const results = await query.find(); + + expect(results.length).toBeGreaterThan(0); + }); + + it('should allow queries with master key even when exceeding depth limit', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 1, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser7b'); + user.setPassword('password'); + await user.signUp(null, { useMasterKey: true }); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(null, { useMasterKey: true }); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(null, { useMasterKey: true }); + + // Query with include depth of 2 (exceeds limit of 1) but using master key + const query = new Parse.Query('Comment'); + query.include('post.author'); + const results = await query.find({ useMasterKey: true }); + + expect(results.length).toBeGreaterThan(0); + }); + + it('should allow queries with maintenance key even when exceeding depth limit', async () => { + await reconfigureServer({ + maintenanceKey: 'maintenanceKey789', + maxIncludeQueryComplexity: { + depth: 1, + }, + }); + + // Create test objects with relationships using Parse SDK + const user = new Parse.User(); + user.setUsername('testuser7c'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with include depth of 2 (exceeds limit of 1) but using maintenance key via REST API + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Maintenance-Key': 'maintenanceKey789', + }; + const response = await request({ + headers, + url: `http://localhost:8378/1/classes/Comment?include=post.author`, + json: true, + }); + + expect(response.data.results.length).toBeGreaterThan(0); + }); + }); + + describe('Combined depth and fields validation', () => { + it('should validate both depth and fields limits', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 2, + count: 3, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser8'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query within both limits (1 field, depth 2) + const query = new Parse.Query('Comment'); + query.include('post.author'); + const results = await query.find(); + + expect(results.length).toBeGreaterThan(0); + }); + + it('should reject if either depth or fields exceeds limit', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 10, // High depth limit + count: 2, // Low count limit + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser9'); + user.setPassword('password'); + await user.signUp(); + + const category = new Parse.Object('Category'); + category.set('name', 'Test Category'); + await category.save(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + post.set('category', category); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Query with 3 fields (exceeds fields limit) but within depth limit + const query = new Parse.Query('Comment'); + query.include('post'); + query.include('post.author'); + query.include('post.category'); + + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + }) + ); + }); + }); + + describe('includeAll blocking with query complexity limits', () => { + it('should block includeAll when maxIncludeQueryComplexity.depth is configured', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 2, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser_includeall_1'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + // Query with includeAll should be blocked + const query = new Parse.Query('Post'); + query.includeAll(); + + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ) + ); + }); + + it('should block includeAll when maxIncludeQueryComplexity.count is configured', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + count: 3, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser_includeall_2'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + // Query with includeAll should be blocked + const query = new Parse.Query('Post'); + query.includeAll(); + + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ) + ); + }); + + it('should block include("*") when maxIncludeQueryComplexity.depth is configured', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 2, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser_includeall_3'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + // Query with include("*") should be blocked + const query = new Parse.Query('Post'); + query.include('*'); + + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ) + ); + }); + + it('should block include("*") when maxIncludeQueryComplexity.count is configured', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + count: 3, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser_includeall_4'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + // Query with include("*") should be blocked + const query = new Parse.Query('Post'); + query.include('*'); + + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ) + ); + }); + + it('should allow includeAll for master key requests', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 2, + count: 3, + }, + }); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser_includeall_5'); + user.setPassword('password'); + await user.signUp(null, { useMasterKey: true }); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(null, { useMasterKey: true }); + + // Query with includeAll should work with master key + const query = new Parse.Query('Post'); + query.includeAll(); + + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBeGreaterThan(0); + }); + + it('should allow includeAll when no complexity limits are configured', async () => { + await reconfigureServer({}); + + // Create test objects with relationships + const user = new Parse.User(); + user.setUsername('testuser_includeall_6'); + user.setPassword('password'); + await user.signUp(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + await post.save(); + + // Query with includeAll should work when no limits are configured + const query = new Parse.Query('Post'); + query.includeAll(); + + const results = await query.find(); + expect(results.length).toBeGreaterThan(0); + }); + }); + + describe('Queries without includes', () => { + it('should allow queries without includes regardless of complexity limits', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 1, + paths: 1, + }, + }); + + const simpleObject = new Parse.Object('SimpleObject'); + simpleObject.set('name', 'Test'); + simpleObject.set('value', 123); + await simpleObject.save(); + + // Query without includes should not be affected by complexity limits + const query = new Parse.Query('SimpleObject'); + const results = await query.find(); + + expect(results.length).toBeGreaterThan(0); + }); + + it('should allow queries with empty includes array', async () => { + await reconfigureServer({ + maxIncludeQueryComplexity: { + depth: 1, + paths: 1, + }, + }); + + const simpleObject = new Parse.Object('SimpleObject'); + simpleObject.set('name', 'Test'); + simpleObject.set('value', 123); + await simpleObject.save(); + + // Query with empty includes should not be affected + const query = new Parse.Query('SimpleObject'); + const results = await query.find(); + + expect(results.length).toBeGreaterThan(0); + }); + }); + + describe('No complexity limits configured', () => { + it('should allow complex queries when no limits are set', async () => { + // Use default config without complexity limits + await reconfigureServer(); + + // Create test objects with deep relationships + const user = new Parse.User(); + user.setUsername('testuser10'); + user.setPassword('password'); + await user.signUp(); + + const category = new Parse.Object('Category'); + category.set('name', 'Test Category'); + await category.save(); + + const post = new Parse.Object('Post'); + post.set('title', 'Test Post'); + post.set('author', user); + post.set('category', category); + await post.save(); + + const comment = new Parse.Object('Comment'); + comment.set('text', 'Test Comment'); + comment.set('post', post); + await comment.save(); + + // Complex query should work without limits + const query = new Parse.Query('Comment'); + query.include('post'); + query.include('post.author'); + query.include('post.category'); + const results = await query.find(); + + expect(results.length).toBeGreaterThan(0); + }); + }); +}); + diff --git a/src/Config.js b/src/Config.js index 241edf9771..b8af265632 100644 --- a/src/Config.js +++ b/src/Config.js @@ -132,6 +132,8 @@ export class Config { databaseOptions, extendSessionOnUse, allowClientClassCreation, + maxIncludeQueryComplexity, + maxGraphQLQueryComplexity, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -173,6 +175,7 @@ export class Config { this.validateDatabaseOptions(databaseOptions); this.validateCustomPages(customPages); this.validateAllowClientClassCreation(allowClientClassCreation); + this.validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity); } static validateCustomPages(customPages) { @@ -230,6 +233,17 @@ export class Config { } } + static validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity) { + if (maxIncludeQueryComplexity && maxGraphQLQueryComplexity) { + if (maxIncludeQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) { + throw new Error('maxIncludeQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth'); + } + if (maxIncludeQueryComplexity.count >= maxGraphQLQueryComplexity.fields) { + throw new Error('maxIncludeQueryComplexity.count must be less than maxGraphQLQueryComplexity.fields'); + } + } + } + static validateSecurityOptions(security) { if (Object.prototype.toString.call(security) !== '[object Object]') { throw 'Parse Server option security must be an object.'; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 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..c45af10119 --- /dev/null +++ b/src/GraphQL/helpers/queryComplexity.js @@ -0,0 +1,127 @@ +import { GraphQLError, getOperationAST, Kind } from 'graphql'; + +/** + * Calculate the maximum depth and fields (field count) of a GraphQL query + * @param {DocumentNode} document - The GraphQL document AST + * @param {string} operationName - Optional operation name to select from multi-operation documents + * @param {Object} maxLimits - Optional maximum limits for early exit optimization + * @param {number} maxLimits.depth - Maximum depth allowed + * @param {number} maxLimits.fields - Maximum fields allowed + * @returns {{ depth: number, fields: number }} Maximum depth and total fields + */ +function calculateQueryComplexity(document, operationName, maxLimits = {}) { + const operationAST = getOperationAST(document, operationName); + if (!operationAST || !operationAST.selectionSet) { + return { depth: 0, fields: 0 }; + } + + // Build fragment definition map + const fragments = {}; + if (document.definitions) { + document.definitions.forEach(def => { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + fragments[def.name.value] = def; + } + }); + } + + let maxDepth = 0; + let fields = 0; + + function visitSelectionSet(selectionSet, depth) { + if (!selectionSet || !selectionSet.selections) { + return; + } + + selectionSet.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) { + fields++; + maxDepth = Math.max(maxDepth, depth); + + // Early exit optimization: throw immediately if limits are exceeded + if (maxLimits.fields && fields > maxLimits.fields) { + throw new GraphQLError( + `Number of fields selected exceeds maximum allowed`, + { + extensions: { + http: { + status: 403, + }, + } + } + ); + } + + if (maxLimits.depth && maxDepth > maxLimits.depth) { + throw new GraphQLError( + `Query depth exceeds maximum allowed depth`, + { + extensions: { + http: { + status: 403, + }, + } + } + ); + } + + if (selection.selectionSet) { + visitSelectionSet(selection.selectionSet, depth + 1); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + // Inline fragments don't add depth, just traverse their selections + visitSelectionSet(selection.selectionSet, depth); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = selection.name.value; + const fragment = fragments[fragmentName]; + // Note: Circular fragments are already prevented by GraphQL validation (NoFragmentCycles rule) + // so we don't need to check for cycles here + if (fragment && fragment.selectionSet) { + visitSelectionSet(fragment.selectionSet, depth); + } + } + }); + } + + visitSelectionSet(operationAST.selectionSet, 1); + return { depth: maxDepth, fields }; +} + +/** + * Create a GraphQL complexity validation plugin for Apollo Server + * Computes depth and total field count directly from the parsed GraphQL document + * @param {Object} config - Parse Server config object + * @returns {Object} Apollo Server plugin + */ +export function createComplexityValidationPlugin(config) { + return { + requestDidStart: () => ({ + didResolveOperation: async (requestContext) => { + const { document, operationName } = requestContext; + const auth = requestContext.contextValue?.auth; + + // Skip validation for master/maintenance keys + if (auth?.isMaster || auth?.isMaintenance) { + return; + } + + // Skip if no complexity limits are configured + if (!config.maxGraphQLQueryComplexity) { + return; + } + + // Skip if document is not available + if (!document) { + return; + } + + const maxGraphQLQueryComplexity = config.maxGraphQLQueryComplexity; + + // Calculate depth and fields in a single pass for performance + // Pass max limits for early exit optimization - will throw immediately if exceeded + // SECURITY: operationName is crucial for multi-operation documents to validate the correct operation + calculateQueryComplexity(document, operationName, maxGraphQLQueryComplexity); + }, + }), + }; +} diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 1ae9512823..5af320595f 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -396,6 +396,18 @@ module.exports.ParseServerOptions = { '(Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server.', action: parsers.numberParser('masterKeyTtl'), }, + maxGraphQLQueryComplexity: { + env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY', + help: + 'Maximum query complexity for GraphQL queries. Controls depth and number of field selections.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of field selections in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + action: parsers.objectParser, + }, + maxIncludeQueryComplexity: { + env: 'PARSE_SERVER_MAX_INCLUDE_QUERY_COMPLEXITY', + help: + 'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.', + action: parsers.objectParser, + }, maxLimit: { env: 'PARSE_SERVER_MAX_LIMIT', help: 'Max value for limit option on queries, defaults to unlimited', diff --git a/src/Options/docs.js b/src/Options/docs.js index cdbd06de45..eb6d9fcd42 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -71,6 +71,8 @@ * @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 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) * @property {String} maxUploadSize Max file size for uploads, defaults to 20mb diff --git a/src/Options/index.js b/src/Options/index.js index 81dbc3c536..7d8ff4c1ca 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -43,6 +43,14 @@ type RequestKeywordDenylist = { key: string | any, value: any, }; +type GraphQLQueryComplexityOptions = { + depth?: number, + fields?: number, +}; +type IncludeComplexityOptions = { + depth?: number, + count?: number, +}; export interface ParseServerOptions { /* Your Parse Application ID @@ -347,6 +355,22 @@ export interface ParseServerOptions { rateLimit: ?(RateLimitOptions[]); /* Options to customize the request context using inversion of control/dependency injection.*/ requestContextMiddleware: ?(req: any, res: any, next: any) => void; + /* Maximum query complexity for REST API includes. Controls depth and number of include fields. + * Format: { depth: number, count: number } + * - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3) + * - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields) + * If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values + * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + */ + maxIncludeQueryComplexity: ?IncludeComplexityOptions; + /* Maximum query complexity for GraphQL queries. Controls depth and number of field selections. + * Format: { depth: number, fields: number } + * - depth: Maximum depth of nested field selections + * - fields: Maximum number of field selections in a single request + * If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values + * must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts. + */ + maxGraphQLQueryComplexity: ?GraphQLQueryComplexityOptions; } export interface RateLimitOptions { diff --git a/src/RestQuery.js b/src/RestQuery.js index dd226f249c..a5f8cb24d5 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -207,6 +207,18 @@ function _UnsafeRestQuery( this.doCount = true; break; case 'includeAll': + // Block includeAll if maxIncludeQueryComplexity is configured for non-master users + if ( + !this.auth.isMaster && + !this.auth.isMaintenance && + this.config.maxIncludeQueryComplexity && + (this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count) + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ); + } this.includeAll = true; break; case 'explain': @@ -236,6 +248,18 @@ function _UnsafeRestQuery( case 'include': { const paths = restOptions.include.split(','); if (paths.includes('*')) { + // Block includeAll if maxIncludeQueryComplexity is configured for non-master users + if ( + !this.auth.isMaster && + !this.auth.isMaintenance && + this.config.maxIncludeQueryComplexity && + (this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count) + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'includeAll is not allowed when query complexity limits are configured' + ); + } this.includeAll = true; break; } @@ -270,6 +294,26 @@ function _UnsafeRestQuery( throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad option: ' + option); } } + + // Validate query complexity for REST includes + if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxIncludeQueryComplexity && this.include && this.include.length > 0) { + const includeCount = this.include.length; + + if (this.config.maxIncludeQueryComplexity.count && includeCount > this.config.maxIncludeQueryComplexity.count) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Number of include fields exceeds maximum allowed` + ); + } + + const depth = Math.max(...this.include.map(path => path.length)); + if (this.config.maxIncludeQueryComplexity.depth && depth > this.config.maxIncludeQueryComplexity.depth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Include depth exceeds maximum allowed` + ); + } + } } // A convenient method to perform all the steps of processing a query diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index ad11050648..49b41e103e 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 {};