mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 19:41:53 +03:00
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:
parent
550756c2bf
commit
2bcce44e08
@ -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',
|
||||||
|
@ -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 })
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
@ -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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
@ -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(', '));
|
||||||
|
}
|
||||||
|
}
|
@ -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 });
|
||||||
|
Loading…
Reference in New Issue
Block a user