From 2bcce44e0860fabb7b262db1b7f16ffabca9f859 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 18 Dec 2024 16:46:59 +0100 Subject: [PATCH] feat(workspace): expand forbidden subdomain validation (#9082) Added new forbidden words and regex patterns to subdomain validation in `update-workspace-input`. Enhanced the `ForbiddenWords` validator to support both strings and regex matching. Updated tests to verify regex-based forbidden subdomain validation. Fix #9064 --------- Co-authored-by: Weiko --- .../settings/workspace/SettingsDomain.tsx | 3 +- .../workspace/dtos/update-workspace-input.ts | 97 ++++++++++++++++++- .../workspace/workspace.resolver.ts | 4 +- ...idden-words-custom-class-validator.spec.ts | 38 -------- .../custom-class-validator/ForbiddenWords.ts | 39 -------- .../graphql-validation-exception.filter.ts | 17 ++++ .../unhandled-exception.filter.ts} | 0 packages/twenty-server/src/main.ts | 14 ++- 8 files changed, 126 insertions(+), 86 deletions(-) delete mode 100644 packages/twenty-server/src/engine/utils/__tests__/forbidden-words-custom-class-validator.spec.ts delete mode 100644 packages/twenty-server/src/engine/utils/custom-class-validator/ForbiddenWords.ts create mode 100644 packages/twenty-server/src/filters/graphql-validation-exception.filter.ts rename packages/twenty-server/src/{utils/apply-cors-to-exceptions.ts => filters/unhandled-exception.filter.ts} (100%) diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 256daeada..8573722ea 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -101,7 +101,8 @@ export const SettingsDomain = () => { } catch (error) { if ( error instanceof Error && - error.message === 'Subdomain already taken' + (error.message === 'Subdomain already taken' || + error.message.endsWith('not allowed')) ) { control.setError('subdomain', { type: 'manual', diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index 19d86e3a3..0b78cb537 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -1,8 +1,12 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean, IsOptional, IsString, Matches } from 'class-validator'; - -import { ForbiddenWords } from 'src/engine/utils/custom-class-validator/ForbiddenWords'; +import { + IsBoolean, + IsOptional, + IsString, + Matches, + IsNotIn, +} from 'class-validator'; @InputType() export class UpdateWorkspaceInput { @@ -14,8 +18,91 @@ export class UpdateWorkspaceInput { @Field({ nullable: true }) @IsString() @IsOptional() - @Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/) - @ForbiddenWords(['demo']) + @Matches(/^(?!api-).*^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/) + @IsNotIn([ + 'demo', + 'api', + 't', + 'companies', + 'telemetry', + 'logs', + 'metrics', + 'next', + 'main', + 'admin', + 'dashboard', + 'dash', + 'billing', + 'db', + 'favicon', + 'www', + 'mail', + 'docs', + 'dev', + 'app', + 'staging', + 'production', + 'developer', + 'files', + 'cdn', + 'storage', + 'about', + 'help', + 'support', + 'contact', + 'privacy', + 'terms', + 'careers', + 'jobs', + 'blog', + 'news', + 'events', + 'community', + 'forum', + 'chat', + 'test', + 'testing', + 'feedback', + 'config', + 'settings', + 'media', + 'image', + 'audio', + 'video', + 'images', + 'partners', + 'partnership', + 'partnerships', + 'assets', + 'login', + 'signin', + 'signup', + 'legal', + 'shop', + 'merch', + 'store', + 'auth', + 'register', + 'payment', + 'fr', + 'de', + 'it', + 'es', + 'pt', + 'nl', + 'be', + 'ch', + 'us', + 'ca', + 'au', + 'nz', + 'za', + 'uk', + 'eu', + 'asia', + 'africa', + 'america', + ]) subdomain?: string; @Field({ nullable: true }) diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index e038c2da8..a10c4ab0e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -1,4 +1,4 @@ -import { UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, @@ -41,6 +41,7 @@ import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { GraphqlValidationExceptionFilter } from 'src/filters/validation-exception.filter'; import { assert } from 'src/utils/assert'; import { isDefined } from 'src/utils/is-defined'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; @@ -50,6 +51,7 @@ import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; @Resolver(() => Workspace) +@UseFilters(GraphqlValidationExceptionFilter) export class WorkspaceResolver { constructor( private readonly workspaceService: WorkspaceService, diff --git a/packages/twenty-server/src/engine/utils/__tests__/forbidden-words-custom-class-validator.spec.ts b/packages/twenty-server/src/engine/utils/__tests__/forbidden-words-custom-class-validator.spec.ts deleted file mode 100644 index b5e83a10f..000000000 --- a/packages/twenty-server/src/engine/utils/__tests__/forbidden-words-custom-class-validator.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { validate } from 'class-validator'; - -import { ForbiddenWords } from 'src/engine/utils/custom-class-validator/ForbiddenWords'; - -describe('ForbiddenWordsConstraint', () => { - test('should throw error when word is forbidden', async () => { - class Test { - @ForbiddenWords(['forbidden', 'restricted']) - subdomain: string; - } - - const instance = new Test(); - - instance.subdomain = 'forbidden'; - - const errors = await validate(instance); - - expect(errors.length).toBeGreaterThan(0); - expect(errors[0].constraints).toEqual({ - ForbiddenWordsConstraint: 'forbidden, restricted are not allowed', - }); - }); - - test('should pass validation word is not in the list', async () => { - class Test { - @ForbiddenWords(['forbidden', 'restricted']) - subdomain: string; - } - - const instance = new Test(); - - instance.subdomain = 'valid'; - - const errors = await validate(instance); - - expect(errors.length).toEqual(0); - }); -}); diff --git a/packages/twenty-server/src/engine/utils/custom-class-validator/ForbiddenWords.ts b/packages/twenty-server/src/engine/utils/custom-class-validator/ForbiddenWords.ts deleted file mode 100644 index 1e3412d9b..000000000 --- a/packages/twenty-server/src/engine/utils/custom-class-validator/ForbiddenWords.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - registerDecorator, - ValidationArguments, - ValidationOptions, - ValidatorConstraint, - ValidatorConstraintInterface, -} from 'class-validator'; - -@ValidatorConstraint({ async: false }) -export class ForbiddenWordsConstraint implements ValidatorConstraintInterface { - private forbiddenWords: Set; - - constructor() {} - - validate(value: string, validationArguments: ValidationArguments) { - this.forbiddenWords = new Set(validationArguments.constraints[0]); - - return !this.forbiddenWords.has(value); - } - - defaultMessage() { - return `${Array.from(this.forbiddenWords).join(', ')} are not allowed`; - } -} - -export function ForbiddenWords( - forbiddenWords: string[], - validationOptions?: ValidationOptions, -) { - return function (object: object, propertyName: string) { - registerDecorator({ - target: object.constructor, - propertyName: propertyName, - options: validationOptions, - constraints: [forbiddenWords], - validator: ForbiddenWordsConstraint, - }); - }; -} diff --git a/packages/twenty-server/src/filters/graphql-validation-exception.filter.ts b/packages/twenty-server/src/filters/graphql-validation-exception.filter.ts new file mode 100644 index 000000000..37f0ae1a6 --- /dev/null +++ b/packages/twenty-server/src/filters/graphql-validation-exception.filter.ts @@ -0,0 +1,17 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; + +import { ValidationError } from 'class-validator'; + +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +@Catch(ValidationError) +export class GraphqlValidationExceptionFilter implements ExceptionFilter { + catch(exception: ValidationError, _host: ArgumentsHost) { + const errors = Object.values(exception.constraints || {}).map((error) => ({ + message: error, + path: exception.property, + })); + + return new UserInputError(errors.map((error) => error.message).join(', ')); + } +} diff --git a/packages/twenty-server/src/utils/apply-cors-to-exceptions.ts b/packages/twenty-server/src/filters/unhandled-exception.filter.ts similarity index 100% rename from packages/twenty-server/src/utils/apply-cors-to-exceptions.ts rename to packages/twenty-server/src/filters/unhandled-exception.filter.ts diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index c15387c59..0a9e75b8d 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -5,14 +5,14 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import fs from 'fs'; import bytes from 'bytes'; -import { useContainer } from 'class-validator'; +import { useContainer, ValidationError } from 'class-validator'; import session from 'express-session'; import { graphqlUploadExpress } from 'graphql-upload'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { LoggerService } from 'src/engine/core-modules/logger/logger.service'; import { getSessionStorageOptions } from 'src/engine/core-modules/session-storage/session-storage.module-factory'; -import { UnhandledExceptionFilter } from 'src/utils/apply-cors-to-exceptions'; +import { UnhandledExceptionFilter } from 'src/filters/unhandled-exception.filter'; import { AppModule } from './app.module'; import './instrument'; @@ -54,6 +54,16 @@ const bootstrap = async () => { app.useGlobalPipes( new ValidationPipe({ transform: true, + exceptionFactory: (errors) => { + const error = new ValidationError(); + + error.constraints = Object.assign( + {}, + ...errors.map((error) => error.constraints), + ); + + return error; + }, }), ); app.useBodyParser('json', { limit: settings.storage.maxFileSize });