diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index fd891ed6d9c..d8331584256 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -33,6 +33,7 @@ import { import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { useSentryTracing } from 'src/engine/core-modules/exception-handler/hooks/use-sentry-tracing'; +import { useComputeComplexity } from 'src/engine/core-modules/graphql/hooks/use-compute-complexity.hook'; import { useDisableIntrospectionForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-for-unauthenticated-users.hook'; import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; @@ -75,6 +76,9 @@ export class GraphQLConfigService useDisableIntrospectionForUnauthenticatedUsers( this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.PRODUCTION, ), + useComputeComplexity( + this.twentyConfigService.get('GRAPHQL_MAX_COMPLEXITY'), + ), ]; if (Sentry.isInitialized()) { diff --git a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts index 7154cf5073c..ed0fa7c7ce7 100644 --- a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts @@ -7,6 +7,7 @@ import { useCachedMetadata } from 'src/engine/api/graphql/graphql-config/hooks/u import { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module'; import { type CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; import { type ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { useComputeComplexity } from 'src/engine/core-modules/graphql/hooks/use-compute-complexity.hook'; import { useDisableIntrospectionForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-for-unauthenticated-users.hook'; import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook'; import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service'; @@ -45,6 +46,7 @@ export const metadataModuleFactory = async ( useDisableIntrospectionForUnauthenticatedUsers( twentyConfigService.get('NODE_ENV') === NodeEnvironment.PRODUCTION, ), + useComputeComplexity(twentyConfigService.get('GRAPHQL_MAX_COMPLEXITY')), ], path: '/metadata', context: () => ({ diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-compute-complexity.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-compute-complexity.hook.ts new file mode 100644 index 00000000000..c0955a5ee4e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-compute-complexity.hook.ts @@ -0,0 +1,33 @@ +import { msg } from '@lingui/core/macro'; +import { type ValidationContext } from 'graphql'; +import { type Plugin } from 'graphql-yoga'; + +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export const useComputeComplexity = (maximumComplexity: number): Plugin => ({ + onValidate: ({ addValidationRule }) => { + addValidationRule((context: ValidationContext) => { + let complexity = 0; + + return { + Field() { + complexity++; + }, + Document: { + leave() { + if (complexity > maximumComplexity) { + context.reportError( + new UserInputError( + `Query complexity is too high: ${complexity} - Too many fields requested`, + { + userFriendlyMessage: msg`The request is too complex to process. Please try reducing the amount of data requested.`, + }, + ), + ); + } + }, + }, + }; + }); + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index d1f8801a778..8904189de5e 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -986,6 +986,14 @@ export class ConfigVariables { @CastToPositiveNumber() API_RATE_LIMITING_LONG_LIMIT = 100; + @CastToPositiveNumber() + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.RATE_LIMITING, + description: 'Maximum complexity allowed for GQL queries', + type: ConfigVariableType.NUMBER, + }) + GRAPHQL_MAX_COMPLEXITY = 2000; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.SSL, description: 'Path to the SSL key for enabling HTTPS in local development', diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts index 0b7504c3ef3..97e5e66105b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts @@ -24,9 +24,9 @@ import { } from 'class-validator'; import { GraphQLJSON } from 'graphql-type-json'; import { - FieldMetadataType, - FieldMetadataSettings, FieldMetadataOptions, + FieldMetadataSettings, + FieldMetadataType, } from 'twenty-shared/types'; import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/generate-gql-fields.util.ts b/packages/twenty-server/test/integration/graphql/suites/query-complexity/generate-gql-fields.util.ts new file mode 100644 index 00000000000..e671171e89c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/generate-gql-fields.util.ts @@ -0,0 +1,3 @@ +export const generateGqlFields = (count: number): string => { + return Array.from({ length: count }, (_) => `id`).join('\n'); +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/query-complexity.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/query-complexity/query-complexity.integration-spec.ts new file mode 100644 index 00000000000..c2da49a3a1e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/query-complexity.integration-spec.ts @@ -0,0 +1,38 @@ +import { generateGqlFields } from 'test/integration/graphql/suites/query-complexity/generate-gql-fields.util'; +import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +describe('Query Complexity', () => { + it('should execute a simple query', async () => { + const gqlFields = generateGqlFields(100); + + const findManyPeopleOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: gqlFields, + }); + + const response = await makeGraphqlAPIRequest(findManyPeopleOperation); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.people).toBeDefined(); + expect(response.body.data.people.edges).toBeDefined(); + }); + + it('should fail to execute a query with too many fields', async () => { + const gqlFields = generateGqlFields(2001); + + const findManyPeopleOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: gqlFields, + }); + + const response = await makeGraphqlAPIRequest(findManyPeopleOperation); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toContain( + 'Query complexity is too high', + ); + }); +});