Ver Fonte

Merge pull request #5908 from weseek/feat/95830-implement-rate-limiter

feat: 95830 implement rate limiter
yuken há 3 anos atrás
pai
commit
d139d7c37a

+ 6 - 0
packages/app/src/server/interfaces/api-rate-limit-config.ts

@@ -0,0 +1,6 @@
+export type IApiRateLimitConfig = {
+  [endpoint: string]: {
+    method: string,
+    consumePoints: number
+  }
+}

+ 55 - 0
packages/app/src/server/middlewares/api-rate-limiter.ts

@@ -0,0 +1,55 @@
+import { NextFunction, Request, Response } from 'express';
+import { RateLimiterMemory } from 'rate-limiter-flexible';
+
+import loggerFactory from '~/utils/logger';
+
+import { generateApiRateLimitConfig } from '../util/generateApiRateLimitConfig';
+
+
+const logger = loggerFactory('growi:middleware:api-rate-limit');
+
+const defaultMaxPoints = 100;
+const defaultConsumePoints = 10;
+const defaultDuration = 1;
+const opts = {
+  points: defaultMaxPoints, // set default value
+  duration: defaultDuration, // set default value
+};
+const rateLimiter = new RateLimiterMemory(opts);
+
+// generate ApiRateLimitConfig for api rate limiter
+const apiRateLimitConfig = generateApiRateLimitConfig();
+
+const consumePoints = async(rateLimiter: RateLimiterMemory, key: string, points: number, next: NextFunction) => {
+  await rateLimiter.consume(key, points)
+    .then(() => {
+      next();
+    })
+    .catch(() => {
+      logger.error(`too many request at ${key}`);
+    });
+};
+
+module.exports = () => {
+
+  return async(req: Request, res: Response, next: NextFunction) => {
+
+    const endpoint = req.path;
+    const key = req.ip + endpoint;
+
+    const customizedConfig = apiRateLimitConfig[endpoint];
+
+    if (customizedConfig === undefined) {
+      await consumePoints(rateLimiter, key, defaultConsumePoints, next);
+      return;
+    }
+
+    if (customizedConfig.method.includes(req.method) || customizedConfig.method === 'ALL') {
+      await consumePoints(rateLimiter, key, customizedConfig.consumePoints, next);
+      return;
+    }
+
+    await consumePoints(rateLimiter, key, defaultConsumePoints, next);
+    return;
+  };
+};

+ 4 - 1
packages/app/src/server/routes/index.js

@@ -9,7 +9,6 @@ import {
   generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
 } from '../middlewares/unavailable-when-maintenance-mode';
 
-
 import * as allInAppNotifications from './all-in-app-notifications';
 import * as forgotPassword from './forgot-password';
 import * as privateLegacyPages from './private-legacy-pages';
@@ -31,6 +30,7 @@ module.exports = function(crowi, app) {
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
   const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
+  const apiRateLimiter = require('../middlewares/api-rate-limiter')();
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const page = require('./page')(crowi, app);
@@ -63,6 +63,9 @@ module.exports = function(crowi, app) {
   // API v3 for auth
   app.use('/_api/v3', apiV3AuthRouter);
 
+  // API rate limiter
+  app.use(apiRateLimiter);
+
   app.get('/'                         , applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);

+ 58 - 0
packages/app/src/server/util/generateApiRateLimitConfig.ts

@@ -0,0 +1,58 @@
+import { IApiRateLimitConfig } from '../interfaces/api-rate-limit-config';
+
+const getTargetFromKey = (key: string) => {
+  return key.replace(/^API_RATE_LIMIT_/, '').replace(/_ENDPOINT$/, '');
+};
+
+const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, endpointKeys: string[]): IApiRateLimitConfig => {
+  const apiRateLimitConfig: IApiRateLimitConfig = {};
+  endpointKeys.forEach((key) => {
+
+    const endpoint = envVar[key];
+
+    if (endpoint == null || Object.keys(apiRateLimitConfig).includes(endpoint)) {
+      return;
+    }
+
+    const target = getTargetFromKey(key);
+    const method = envVar[`API_RATE_LIMIT_${target}_METHODS`] ?? 'ALL';
+    const consumePoints = Number(envVar[`API_RATE_LIMIT_${target}_CONSUME_POINTS`]);
+
+    if (endpoint == null || consumePoints == null) {
+      return;
+    }
+
+    const config = {
+      method,
+      consumePoints,
+    };
+
+    apiRateLimitConfig[endpoint] = config;
+  });
+
+  return apiRateLimitConfig;
+};
+
+// this method is called only one server starts
+export const generateApiRateLimitConfig = (): IApiRateLimitConfig => {
+  const envVar = process.env;
+
+  const apiRateEndpointKeys = Object.keys(envVar).filter((key) => {
+    const endpointRegExp = /^API_RATE_LIMIT_\w+_ENDPOINT/;
+    return endpointRegExp.test(key);
+  });
+
+  // sort priority
+  apiRateEndpointKeys.sort().reverse();
+
+  // get config
+  const apiRateLimitConfig = generateApiRateLimitConfigFromEndpoint(envVar, apiRateEndpointKeys);
+
+  // default setting e.g. healthchack
+  apiRateLimitConfig['/_api/v3/healthcheck'] = {
+    method: 'GET',
+    consumePoints: 0,
+  };
+
+  return apiRateLimitConfig;
+};