Rate limit Mutation.inviteToOrganizationByEmail (#4017)

This commit is contained in:
Kamil Kisiela 2024-02-21 14:24:42 +01:00 committed by GitHub
parent cc1635da45
commit 34e1ca40ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 129 additions and 1 deletions

View file

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

View file

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

View file

@ -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<string, number[]>;
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;
}
}