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 <corentin@twenty.com>
This commit is contained in:
Antoine Moreaux 2024-12-18 16:46:59 +01:00 committed by GitHub
parent 550756c2bf
commit 2bcce44e08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 126 additions and 86 deletions

View File

@ -101,7 +101,8 @@ export const SettingsDomain = () => {
} catch (error) { } catch (error) {
if ( if (
error instanceof Error && error instanceof Error &&
error.message === 'Subdomain already taken' (error.message === 'Subdomain already taken' ||
error.message.endsWith('not allowed'))
) { ) {
control.setError('subdomain', { control.setError('subdomain', {
type: 'manual', type: 'manual',

View File

@ -1,8 +1,12 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString, Matches } from 'class-validator'; import {
IsBoolean,
import { ForbiddenWords } from 'src/engine/utils/custom-class-validator/ForbiddenWords'; IsOptional,
IsString,
Matches,
IsNotIn,
} from 'class-validator';
@InputType() @InputType()
export class UpdateWorkspaceInput { export class UpdateWorkspaceInput {
@ -14,8 +18,91 @@ export class UpdateWorkspaceInput {
@Field({ nullable: true }) @Field({ nullable: true })
@IsString() @IsString()
@IsOptional() @IsOptional()
@Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/) @Matches(/^(?!api-).*^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/)
@ForbiddenWords(['demo']) @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; subdomain?: string;
@Field({ nullable: true }) @Field({ nullable: true })

View File

@ -1,4 +1,4 @@
import { UseGuards } from '@nestjs/common'; import { UseFilters, UseGuards } from '@nestjs/common';
import { import {
Args, Args,
Mutation, 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 { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-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 { assert } from 'src/utils/assert';
import { isDefined } from 'src/utils/is-defined'; import { isDefined } from 'src/utils/is-defined';
import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { streamToBuffer } from 'src/utils/stream-to-buffer';
@ -50,6 +51,7 @@ import { Workspace } from './workspace.entity';
import { WorkspaceService } from './services/workspace.service'; import { WorkspaceService } from './services/workspace.service';
@Resolver(() => Workspace) @Resolver(() => Workspace)
@UseFilters(GraphqlValidationExceptionFilter)
export class WorkspaceResolver { export class WorkspaceResolver {
constructor( constructor(
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,

View File

@ -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);
});
});

View File

@ -1,39 +0,0 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: false })
export class ForbiddenWordsConstraint implements ValidatorConstraintInterface {
private forbiddenWords: Set<string>;
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,
});
};
}

View File

@ -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(', '));
}
}

View File

@ -5,14 +5,14 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import fs from 'fs'; import fs from 'fs';
import bytes from 'bytes'; import bytes from 'bytes';
import { useContainer } from 'class-validator'; import { useContainer, ValidationError } from 'class-validator';
import session from 'express-session'; import session from 'express-session';
import { graphqlUploadExpress } from 'graphql-upload'; import { graphqlUploadExpress } from 'graphql-upload';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { LoggerService } from 'src/engine/core-modules/logger/logger.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 { 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 { AppModule } from './app.module';
import './instrument'; import './instrument';
@ -54,6 +54,16 @@ const bootstrap = async () => {
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
transform: true, 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 }); app.useBodyParser('json', { limit: settings.storage.maxFileSize });