Security - add throttle in message resend (#16070)

closes https://github.com/twentyhq/private-issues/issues/356
This commit is contained in:
Etienne
2025-11-28 18:33:05 +01:00
committed by GitHub
parent 9f62188ba6
commit 63afed6400
5 changed files with 93 additions and 3 deletions
@@ -975,7 +975,7 @@ export class ConfigVariables {
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
API_RATE_LIMITING_LONG_TTL_IN_MS = 60000;
API_RATE_LIMITING_LONG_TTL_IN_MS = 60_000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RATE_LIMITING,
@@ -994,6 +994,42 @@ export class ConfigVariables {
})
GRAPHQL_MAX_COMPLEXITY = 2000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RATE_LIMITING,
description:
'Time-to-live for workspace-level invitations resending rate limiting in milliseconds',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
INVITATION_SENDING_BY_WORKSPACE_THROTTLE_TTL_IN_MS = 604_800_000; // 7 days
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RATE_LIMITING,
description:
'Maximum number of workspace-level invitations resending allowed in the rate limiting window',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
INVITATION_SENDING_BY_WORKSPACE_THROTTLE_LIMIT = 500;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RATE_LIMITING,
description:
'Time-to-live for email-level invitations sending rate limiting in milliseconds',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
INVITATION_SENDING_BY_EMAIL_THROTTLE_TTL_IN_MS = 604_800_000; // 7 days
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RATE_LIMITING,
description:
'Maximum number of email-level invitations sending allowed in the rate limiting window',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
INVITATION_SENDING_BY_EMAIL_THROTTLE_LIMIT = 10;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SSL,
description: 'Path to the SSL key for enabling HTTPS in local development',
@@ -12,6 +12,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
@@ -112,6 +113,12 @@ describe('WorkspaceInvitationService', () => {
.mockReturnValue('https://signed-url.com/logo.png'),
},
},
{
provide: ThrottlerService,
useValue: {
tokenBucketThrottleOrThrow: jest.fn(),
},
},
],
}).compile();
@@ -25,6 +25,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output';
@@ -49,6 +50,7 @@ export class WorkspaceInvitationService {
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly i18nService: I18nService,
private readonly fileService: FileService,
private readonly throttlerService: ThrottlerService,
) {}
async validatePersonalInvitation({
@@ -252,6 +254,8 @@ export class WorkspaceInvitationService {
};
}
await this.throttleInvitationSending(workspace.id, emails);
const invitationsPr = await Promise.allSettled(
emails.map(async (email) => {
if (usePersonalInvitation) {
@@ -411,4 +415,47 @@ export class WorkspaceInvitationService {
return this.appTokenRepository.save(invitationToken);
}
private async throttleInvitationSending(
workspaceId: string,
emails: string[],
) {
try {
//limit invitation sending for specific invite emails
await Promise.all(
emails.map(async (email) => {
await this.throttlerService.tokenBucketThrottleOrThrow(
`invitation-resending-workspace:throttler:${email}`,
1,
this.twentyConfigService.get(
'INVITATION_SENDING_BY_EMAIL_THROTTLE_LIMIT',
),
this.twentyConfigService.get(
'INVITATION_SENDING_BY_EMAIL_THROTTLE_TTL_IN_MS',
),
);
}),
);
//limit invitation sending for a specific workspace
await this.throttlerService.tokenBucketThrottleOrThrow(
`invitation-resending-workspace:throttler:${workspaceId}`,
emails.length,
this.twentyConfigService.get(
'INVITATION_SENDING_BY_WORKSPACE_THROTTLE_LIMIT',
),
this.twentyConfigService.get(
'INVITATION_SENDING_BY_WORKSPACE_THROTTLE_TTL_IN_MS',
),
);
} catch {
throw new WorkspaceInvitationException(
'Workspace invitation sending rate limit exceeded.',
WorkspaceInvitationExceptionCode.INVALID_INVITATION,
{
userFriendlyMessage: msg`Too many workspace invitations sent. Please try again later.`,
},
);
}
}
}
@@ -7,6 +7,7 @@ import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver';
@@ -25,6 +26,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
OnboardingModule,
PermissionsModule,
FeatureFlagModule,
ThrottlerModule,
],
exports: [WorkspaceInvitationService],
providers: [WorkspaceInvitationService, WorkspaceInvitationResolver],
@@ -1,7 +1,6 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -35,7 +34,6 @@ export class WorkspaceInvitationResolver {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly fileService: FileService,
) {}
@Mutation(() => String)