From 34e1ca40ba49fe679a763a219bc179de5627e374 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Wed, 21 Feb 2024 14:24:42 +0100 Subject: [PATCH] Rate limit Mutation.inviteToOrganizationByEmail (#4017) --- .../api/src/modules/organization/resolvers.ts | 8 ++ .../api/src/modules/rate-limit/index.ts | 3 +- .../providers/in-memory-rate-limiter.ts | 119 ++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 packages/services/api/src/modules/rate-limit/providers/in-memory-rate-limiter.ts diff --git a/packages/services/api/src/modules/organization/resolvers.ts b/packages/services/api/src/modules/organization/resolvers.ts index 65f5bde0d..09421619c 100644 --- a/packages/services/api/src/modules/organization/resolvers.ts +++ b/packages/services/api/src/modules/organization/resolvers.ts @@ -8,6 +8,7 @@ import { } from '../auth/providers/organization-access'; import { isProjectScope, ProjectAccessScope } from '../auth/providers/project-access'; import { isTargetScope, TargetAccessScope } from '../auth/providers/target-access'; +import { InMemoryRateLimiter } from '../rate-limit/providers/in-memory-rate-limiter'; import { IdTranslator } from '../shared/providers/id-translator'; import { Logger } from '../shared/providers/logger'; import type { OrganizationModule } from './__generated__/types'; @@ -255,6 +256,13 @@ export const resolvers: OrganizationModule.Resolvers = { }; }, async inviteToOrganizationByEmail(_, { input }, { injector }) { + await injector.get(InMemoryRateLimiter).check( + 'inviteToOrganizationByEmail', + 5_000, // 5 seconds + 6, // 6 invites + `Exceeded rate limit for inviting to organization by email.`, + ); + const InputModel = z.object({ email: z.string().email().max(128, 'Email must be at most 128 characters long'), }); diff --git a/packages/services/api/src/modules/rate-limit/index.ts b/packages/services/api/src/modules/rate-limit/index.ts index 24e885d43..a4e0f44da 100644 --- a/packages/services/api/src/modules/rate-limit/index.ts +++ b/packages/services/api/src/modules/rate-limit/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { InMemoryRateLimiter, InMemoryRateLimitStore } from './providers/in-memory-rate-limiter'; import { RateLimitProvider } from './providers/rate-limit.provider'; import { resolvers } from './resolvers'; import typeDefs from './module.graphql'; @@ -8,5 +9,5 @@ export const rateLimitModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [RateLimitProvider], + providers: [RateLimitProvider, InMemoryRateLimitStore, InMemoryRateLimiter], }); diff --git a/packages/services/api/src/modules/rate-limit/providers/in-memory-rate-limiter.ts b/packages/services/api/src/modules/rate-limit/providers/in-memory-rate-limiter.ts new file mode 100644 index 000000000..a7e0bed64 --- /dev/null +++ b/packages/services/api/src/modules/rate-limit/providers/in-memory-rate-limiter.ts @@ -0,0 +1,119 @@ +import { Injectable, Scope } from 'graphql-modules'; +import LRU from 'lru-cache'; +import { HiveError } from '../../../shared/errors'; +import { AuthManager } from '../../auth/providers/auth-manager'; +import { Logger } from '../../shared/providers/logger'; + +@Injectable({ + scope: Scope.Singleton, +}) +export class InMemoryRateLimitStore { + limiters = new Map< + string, + { + windowSize: number; + maxActions: number; + limiter: SlidingWindowRateLimiter; + } + >(); + + ensureLimiter(action: string, windowSize: number, maxActions: number) { + const existing = this.limiters.get(action); + if (existing) { + if (existing.maxActions !== maxActions || existing.windowSize !== windowSize) { + throw new Error( + `Rate limiter for action "${action}" already exists with different window size or max actions.`, + ); + } + + return existing.limiter; + } + + const limiter = new SlidingWindowRateLimiter(windowSize, maxActions); + this.limiters.set(action, { + windowSize, + maxActions, + limiter, + }); + + return limiter; + } +} + +@Injectable({ + global: true, + scope: Scope.Operation, +}) +export class InMemoryRateLimiter { + constructor( + private logger: Logger, + private store: InMemoryRateLimitStore, + private authManager: AuthManager, + ) { + this.logger = logger.child({ service: 'InMemoryRateLimiter' }); + } + + async check(action: string, windowSizeInMs: number, maxActions: number, message: string) { + this.logger.debug( + 'Checking rate limit (action:%s, windowsSize: %s, maxActions: %s)', + action, + windowSizeInMs, + maxActions, + ); + if (!this.authManager.isUser()) { + throw new Error('Expected to be called for an authenticated user.'); + } + + const user = await this.authManager.getCurrentUser(); + const limiter = this.store.ensureLimiter(action, windowSizeInMs, maxActions); + + if (!limiter.isAllowed(user.id)) { + throw new HiveError(message); + } + } +} + +class SlidingWindowRateLimiter { + private windowSize: number; + private maxActions: number; + private userActions: LRU; + + constructor(windowSize: number, maxActions: number) { + this.windowSize = windowSize; + this.maxActions = maxActions; + this.userActions = new LRU({ + max: 500, + maxAge: windowSize, + }); + } + + isAllowed(userId: string): boolean { + const now = Date.now(); + const userTimestamps = this.userActions.get(userId) || []; + + const recentTimestamps: number[] = []; + + // Remove timestamps that are outside the sliding window + for (let index = userTimestamps.length - 1; index >= 0; index--) { + const timestamp = userTimestamps[index]; + + if (now - timestamp <= this.windowSize) { + recentTimestamps.unshift(timestamp); + } else { + // Stop when we reach the first timestamp outside the window. + // This is because the timestamps are ordered from most recent to oldest + // (We iterate from the end of the array) + break; + } + } + + // Add the current timestamp to the list + recentTimestamps.push(now); + + // Update the user's timestamp list + this.userActions.set(userId, recentTimestamps); + + // Check if the number of actions is within the allowed limit + return recentTimestamps.length <= this.maxActions; + } +}