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:
@ -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={[
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import { CreateContextFactory } from './create-context.factory';
|
||||
|
||||
export const graphQLFactories = [CreateContextFactory];
|
||||
@ -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 {}
|
||||
@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()]);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user