Add rate limiting in the server using built in Nest.js capability (#3566)

* Add rate limiting in the server using built in Nest.js capability

* Generatekey based on ip address when an http request is sent

* Update env var types to number for ttl and limit

* Remove unused env variables

* Use getRequest utility function

* fix: remove dist from path

* fix: adding .env variables

* fix: remove unused functions

* feat: throttler plugin

* Fix according to review

---------

Co-authored-by: Jérémy Magrin <jeremy.magrin@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Joe S
2024-02-07 11:11:32 -06:00
committed by GitHub
parent 3831ddc002
commit 850eab8f8f
16 changed files with 413 additions and 59 deletions

View File

@ -287,4 +287,12 @@ export class EnvironmentService {
isSignUpDisabled(): boolean {
return this.configService.get<boolean>('IS_SIGN_UP_DISABLED') ?? false;
}
getApiRateLimitingTtl(): number {
return this.configService.get<number>('API_RATE_LIMITING_TTL') ?? 100;
}
getApiRateLimitingLimit(): number {
return this.configService.get<number>('API_RATE_LIMITING_LIMIT') ?? 200;
}
}

View File

@ -1,5 +1,4 @@
import { GraphQLError, Kind, OperationDefinitionNode, print } from 'graphql';
import * as express from 'express';
import {
getDocumentString,
handleStreamOrSingleExecutionResult,
@ -7,10 +6,9 @@ import {
Plugin,
} from '@envelop/core';
import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface';
import { GraphQLContext } from 'src/graphql-config/interfaces/graphql-context.interface';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
import { TokenService } from 'src/core/auth/services/token.service';
import {
convertExceptionToGraphQLError,
filterException,
@ -18,13 +16,9 @@ import {
export type ExceptionHandlerPluginOptions = {
/**
* The driver to use to handle exceptions.
* The exception handler service to use.
*/
exceptionHandlerService: ExceptionHandlerService;
/**
* The token service to use to get the token from the request.
*/
tokenService: TokenService;
/**
* The key of the event id in the error's extension. `null` to disable.
* @default exceptionEventId
@ -32,13 +26,9 @@ export type ExceptionHandlerPluginOptions = {
eventIdKey?: string | null;
};
export const useExceptionHandler = <
PluginContext extends Record<string, any> = object,
>(
export const useExceptionHandler = <PluginContext extends GraphQLContext>(
options: ExceptionHandlerPluginOptions,
): Plugin<PluginContext> => {
const exceptionHandlerService = options.exceptionHandlerService;
const tokenService = options.tokenService;
const eventIdKey = options.eventIdKey === null ? null : 'exceptionEventId';
function addEventId(
@ -54,28 +44,17 @@ export const useExceptionHandler = <
return {
async onExecute({ args }) {
const exceptionHandlerService = options.exceptionHandlerService;
const rootOperation = args.document.definitions.find(
(o) => o.kind === Kind.OPERATION_DEFINITION,
) as OperationDefinitionNode;
const operationType = rootOperation.operation;
const user = args.contextValue.user;
const document = getDocumentString(args.document, print);
const request = args.contextValue.req as express.Request;
const opName =
args.operationName ||
rootOperation.name?.value ||
'Anonymous Operation';
let user: ExceptionHandlerUser | undefined;
if (tokenService.isTokenPresent(request)) {
try {
const data = await tokenService.validateToken(request);
user = {
id: data.user?.id,
email: data.user?.email,
};
} catch {}
}
return {
onExecuteDone(payload) {

View File

@ -0,0 +1,86 @@
import { GraphQLResolveInfo } from 'graphql';
import { getGraphQLRateLimiter } from 'graphql-rate-limit';
import { Plugin } from '@envelop/core';
import { useOnResolve } from '@envelop/on-resolve';
import { GraphQLContext } from 'src/graphql-config/graphql-config.service';
export class UnauthenticatedError extends Error {}
export type IdentifyFn<ContextType = ThrottlerContext> = (
context: ContextType,
) => string;
export type ThrottlerPluginOptions = {
identifyFn: IdentifyFn;
ttl?: number;
limit?: number;
transformError?: (message: string) => Error;
onThrottlerError?: (event: {
error: string;
identifier: string;
context: unknown;
info: GraphQLResolveInfo;
}) => void;
};
interface ThrottlerContext extends GraphQLContext {
rateLimiterFn: ReturnType<typeof getGraphQLRateLimiter>;
}
export const useThrottler = (
options: ThrottlerPluginOptions,
): Plugin<ThrottlerContext> => {
const rateLimiterFn = getGraphQLRateLimiter({
identifyContext: options.identifyFn,
});
return {
onPluginInit({ addPlugin }) {
addPlugin(
useOnResolve(async ({ args, root, context, info }) => {
if (options.limit && options.ttl) {
const id = options.identifyFn(context);
const errorMessage = await context.rateLimiterFn(
{ parent: root, args, context, info },
{
max: options?.limit,
window: `${options?.ttl}s`,
message: interpolate('Too much request.', {
id,
}),
},
);
if (errorMessage) {
if (options.onThrottlerError) {
options.onThrottlerError({
error: errorMessage,
identifier: id,
context,
info,
});
}
if (options.transformError) {
throw options.transformError(errorMessage);
}
throw new Error(errorMessage);
}
}
}),
);
},
async onContextBuilding({ extendContext }) {
extendContext({
rateLimiterFn,
});
},
};
};
function interpolate(message: string, args: { [key: string]: string }) {
return message.replace(/\{{([^)]*)\}}/g, (_, key) => args[key.trim()]);
}