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

@ -43,6 +43,12 @@ import TabItem from '@theme/TabItem';
['PORT', '3000', 'Port'],
]}></OptionTable>
### Security
<OptionTable options={[
['API_RATE_LIMITING_TTL', '100', 'API rate limiting time window'],
['API_RATE_LIMITING_LIMIT', '200', 'API rate limiting max requests'],
]}></OptionTable>
### Tokens
<OptionTable options={[

View File

@ -52,3 +52,5 @@ SIGN_IN_PREFILLED=true
# EMAIL_SMTP_USER=
# EMAIL_SMTP_PASSWORD=
# PASSWORD_RESET_TOKEN_EXPIRES_IN=5m
# API_RATE_LIMITING_TTL=
# API_RATE_LIMITING_LIMIT=

View File

@ -10,7 +10,6 @@ ACCESS_TOKEN_SECRET=secret_jwt
LOGIN_TOKEN_SECRET=secret_login_tokens
REFRESH_TOKEN_SECRET=secret_refresh_token
# ———————— Optional ————————
# DEBUG_MODE=false
# SIGN_IN_PREFILLED=false
@ -21,4 +20,4 @@ REFRESH_TOKEN_SECRET=secret_refresh_token
# AUTH_GOOGLE_ENABLED=false
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage
# STORAGE_LOCAL_PATH=.local-storage

View File

@ -36,6 +36,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.363.0",
"@aws-sdk/credential-providers": "^3.363.0",
"@envelop/on-resolve": "^4.1.0",
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch",
"@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.1",
@ -70,6 +71,8 @@
"googleapis": "105",
"graphql": "^16.8.1",
"graphql-fields": "^2.0.3",
"graphql-middleware": "^6.1.35",
"graphql-rate-limit": "^3.3.0",
"graphql-subscriptions": "2.0.0",
"graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2",

View File

@ -4,12 +4,13 @@ import { ConfigModule } from '@nestjs/config';
import { YogaDriver, YogaDriverConfig } from '@graphql-yoga/nestjs';
import { GraphQLConfigService } from 'src/graphql-config.service';
import { GraphQLConfigService } from 'src/graphql-config/graphql-config.service';
import { CoreModule } from './core/core.module';
import { IntegrationsModule } from './integrations/integrations.module';
import { HealthModule } from './health/health.module';
import { WorkspaceModule } from './workspace/workspace.module';
import { GraphQLConfigModule } from './graphql-config/graphql-config.module';
@Module({
imports: [
@ -18,7 +19,7 @@ import { WorkspaceModule } from './workspace/workspace.module';
}),
GraphQLModule.forRootAsync<YogaDriverConfig>({
driver: YogaDriver,
imports: [CoreModule],
imports: [CoreModule, GraphQLConfigModule],
useClass: GraphQLConfigService,
}),
HealthModule,

View File

@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { YogaDriverServerContext } from '@graphql-yoga/nestjs';
import { GraphQLContext } from 'src/graphql-config/interfaces/graphql-context.interface';
import { TokenService } from 'src/core/auth/services/token.service';
@Injectable()
export class CreateContextFactory {
constructor(private readonly tokenService: TokenService) {}
async create(
context: YogaDriverServerContext<'express'>,
): Promise<GraphQLContext> {
// Check if token is present in the request
if (this.tokenService.isTokenPresent(context.req)) {
const data = await this.tokenService.validateToken(context.req);
// Inject user and workspace into the context
return { ...context, ...data };
}
return context;
}
}

View File

@ -0,0 +1,3 @@
import { CreateContextFactory } from './create-context.factory';
export const graphQLFactories = [CreateContextFactory];

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CoreModule } from 'src/core/core.module';
import { graphQLFactories } from 'src/graphql-config/factories';
@Module({
imports: [CoreModule],
providers: [...graphQLFactories],
exports: [...graphQLFactories],
})
export class GraphQLConfigModule {}

View File

@ -19,15 +19,23 @@ import { ExceptionHandlerService } from 'src/integrations/exception-handler/exce
import { handleExceptionAndConvertToGraphQLError } from 'src/filters/utils/global-exception-handler.util';
import { renderApolloPlayground } from 'src/workspace/utils/render-apollo-playground.util';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { useExceptionHandler } from 'src/integrations/exception-handler/hooks/use-exception-handler.hook';
import { User } from 'src/core/user/user.entity';
import { useThrottler } from 'src/integrations/throttler/hooks/use-throttler';
import { useExceptionHandler } from './integrations/exception-handler/hooks/use-exception-handler.hook';
import { User } from './core/user/user.entity';
import { CreateContextFactory } from './factories/create-context.factory';
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
user?: User;
workspace?: Workspace;
}
@Injectable()
export class GraphQLConfigService
implements GqlOptionsFactory<YogaDriverConfig<'express'>>
{
constructor(
private readonly createContextFactory: CreateContextFactory,
private readonly tokenService: TokenService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly environmentService: EnvironmentService,
@ -37,7 +45,7 @@ export class GraphQLConfigService
createGqlOptions(): YogaDriverConfig {
const isDebugMode = this.environmentService.isDebugMode();
const config: YogaDriverConfig = {
context: ({ req }) => ({ req }),
context: (context) => this.createContextFactory.create(context),
autoSchemaFile: true,
include: [CoreModule],
conditionalSchema: async (context) => {
@ -93,9 +101,15 @@ export class GraphQLConfigService
},
resolvers: { JSON: GraphQLJSON },
plugins: [
useThrottler({
ttl: this.environmentService.getApiRateLimitingTtl(),
limit: this.environmentService.getApiRateLimitingLimit(),
identifyFn: (context) => {
return context.user?.id ?? context.req.ip ?? 'anonymous';
},
}),
useExceptionHandler({
exceptionHandlerService: this.exceptionHandlerService,
tokenService: this.tokenService,
}),
],
};

View File

@ -0,0 +1,9 @@
import { YogaDriverServerContext } from '@graphql-yoga/nestjs';
import { User } from 'src/core/user/user.entity';
import { Workspace } from 'src/core/workspace/workspace.entity';
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
user?: User;
workspace?: Workspace;
}

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

View File

@ -1,20 +1,23 @@
import { YogaDriverConfig } from '@graphql-yoga/nestjs';
import GraphQLJSON from 'graphql-type-json';
import { TokenService } from 'src/core/auth/services/token.service';
import { CreateContextFactory } from 'src/graphql-config/factories/create-context.factory';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
import { useExceptionHandler } from 'src/integrations/exception-handler/hooks/use-exception-handler.hook';
import { useThrottler } from 'src/integrations/throttler/hooks/use-throttler';
import { MetadataModule } from 'src/metadata/metadata.module';
import { renderApolloPlayground } from 'src/workspace/utils/render-apollo-playground.util';
export const metadataModuleFactory = async (
environmentService: EnvironmentService,
exceptionHandlerService: ExceptionHandlerService,
tokenService: TokenService,
createContextFactory: CreateContextFactory,
): Promise<YogaDriverConfig> => {
const config: YogaDriverConfig = {
context: ({ req }) => ({ req }),
context(context) {
return createContextFactory.create(context);
},
autoSchemaFile: true,
include: [MetadataModule],
renderGraphiQL() {
@ -22,9 +25,15 @@ export const metadataModuleFactory = async (
},
resolvers: { JSON: GraphQLJSON },
plugins: [
useThrottler({
ttl: environmentService.getApiRateLimitingTtl(),
limit: environmentService.getApiRateLimitingLimit(),
identifyFn: (context) => {
return context.user?.id ?? context.req.ip ?? 'anonymous';
},
}),
useExceptionHandler({
exceptionHandlerService,
tokenService,
}),
],
path: '/metadata',

View File

@ -8,20 +8,25 @@ import { WorkspaceMigrationModule } from 'src/metadata/workspace-migration/works
import { metadataModuleFactory } from 'src/metadata/metadata.module-factory';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
import { TokenService } from 'src/core/auth/services/token.service';
import { AuthModule } from 'src/core/auth/auth.module';
import { GraphQLConfigModule } from 'src/graphql-config/graphql-config.module';
import { CreateContextFactory } from 'src/graphql-config/factories/create-context.factory';
import { DataSourceModule } from './data-source/data-source.module';
import { FieldMetadataModule } from './field-metadata/field-metadata.module';
import { ObjectMetadataModule } from './object-metadata/object-metadata.module';
import { RelationMetadataModule } from './relation-metadata/relation-metadata.module';
@Module({
imports: [
GraphQLModule.forRootAsync<YogaDriverConfig>({
driver: YogaDriver,
useFactory: metadataModuleFactory,
imports: [AuthModule],
inject: [EnvironmentService, ExceptionHandlerService, TokenService],
imports: [GraphQLConfigModule],
inject: [
EnvironmentService,
ExceptionHandlerService,
CreateContextFactory,
],
}),
DataSourceModule,
FieldMetadataModule,