From 7eaa8eb9611f5423187221da79da5e4d3095da9f Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Thu, 11 Aug 2022 15:40:30 +0530 Subject: [PATCH] feat: Put request rate limit at application server (#3857) * feat: Put request rate limit at application server This PR contains implementation for a blanket rate limiter at application server level. Currently the allowed throughput is set high only to be changed later as per the actual data gathered. * Simplify implementation 1. Remove shutdown handler to purge rate limiter keys 2. Have separate keys for default and custom(route-based) rate limiters 3. Do not kill default rate limiter because it is not needed anymore due to (2) above * Set 60s as default for rate limiting window * Fix env types --- .env.sample | 7 ++++ package.json | 1 + server/RateLimiter.ts | 33 +++++++++++++++++ server/env.ts | 31 ++++++++++++++++ server/index.ts | 1 + server/middlewares/rateLimiter.ts | 60 +++++++++++++++++++++++++++++++ server/routes/api/index.ts | 3 ++ server/types.ts | 7 ++++ yarn.lock | 5 +++ 9 files changed, 148 insertions(+) create mode 100644 server/RateLimiter.ts create mode 100644 server/middlewares/rateLimiter.ts diff --git a/.env.sample b/.env.sample index b5c8ca968..954ddd554 100644 --- a/.env.sample +++ b/.env.sample @@ -170,3 +170,10 @@ SMTP_SECURE=true # The default interface language. See translate.getoutline.com for a list of # available language codes and their rough percentage translated. DEFAULT_LANGUAGE=en_US + +# Optionally enable rate limiter at application web server +RATE_LIMITER_ENABLED=true + +# Configure default throttling paramaters for rate limiter +RATE_LIMITER_REQUESTS=5000 +RATE_LIMITER_DURATION_WINDOW=60 diff --git a/package.json b/package.json index f83760986..e51603447 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "query-string": "^7.0.1", "quoted-printable": "^1.0.1", "randomstring": "1.1.5", + "rate-limiter-flexible": "^2.3.7", "raw-loader": "^0.5.1", "react": "^17.0.2", "react-avatar-editor": "^11.1.0", diff --git a/server/RateLimiter.ts b/server/RateLimiter.ts new file mode 100644 index 000000000..685d0eaa2 --- /dev/null +++ b/server/RateLimiter.ts @@ -0,0 +1,33 @@ +import { RateLimiterRedis } from "rate-limiter-flexible"; +import env from "@server/env"; +import Redis from "@server/redis"; +import { RateLimiterConfig } from "@server/types"; + +export default class RateLimiter { + constructor() { + throw Error(`Cannot instantiate class!`); + } + + static readonly RATE_LIMITER_REDIS_KEY_PREFIX = "rl"; + + static readonly rateLimiterMap = new Map(); + static readonly defaultRateLimiter = new RateLimiterRedis({ + storeClient: Redis.defaultClient, + points: env.RATE_LIMITER_REQUESTS, + duration: env.RATE_LIMITER_DURATION_WINDOW, + keyPrefix: this.RATE_LIMITER_REDIS_KEY_PREFIX, + }); + + static getRateLimiter(path: string): RateLimiterRedis { + return this.rateLimiterMap.get(path) || this.defaultRateLimiter; + } + + static setRateLimiter(path: string, config: RateLimiterConfig): void { + const rateLimiter = new RateLimiterRedis(config); + this.rateLimiterMap.set(path, rateLimiter); + } + + static hasRateLimiter(path: string): boolean { + return this.rateLimiterMap.has(path); + } +} diff --git a/server/env.ts b/server/env.ts index 3368db972..7a17e4f20 100644 --- a/server/env.ts +++ b/server/env.ts @@ -495,6 +495,37 @@ export class Environment { process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION ); + /** + * A boolean switch to toggle the rate limiter + * at application web server. + */ + @IsOptional() + @IsBoolean() + public RATE_LIMITER_ENABLED = this.toBoolean( + process.env.RATE_LIMITER_ENABLED ?? "false" + ); + + /** + * Set max allowed requests in a given duration for + * default rate limiter to trigger throttling. + */ + @IsOptional() + @IsNumber() + @CannotUseWithout("RATE_LIMITER_ENABLED") + public RATE_LIMITER_REQUESTS = + this.toOptionalNumber(process.env.RATE_LIMITER_REQUESTS) ?? 5000; + + /** + * Set fixed duration window(in secs) for + * default rate limiter, elapsing which the request + * quota is reset(the bucket is refilled with tokens). + */ + @IsOptional() + @IsNumber() + @CannotUseWithout("RATE_LIMITER_ENABLED") + public RATE_LIMITER_DURATION_WINDOW = + this.toOptionalNumber(process.env.RATE_LIMITER_DURATION_WINDOW) ?? 60; + private toOptionalString(value: string | undefined) { return value ? value : undefined; } diff --git a/server/index.ts b/server/index.ts index 8688b8b74..06d88b303 100644 --- a/server/index.ts +++ b/server/index.ts @@ -122,6 +122,7 @@ async function start(id: number, disconnect: () => void) { }` ); }); + server.listen(normalizedPortFlag || env.PORT || "3000"); process.once("SIGTERM", shutdown); process.once("SIGINT", shutdown); diff --git a/server/middlewares/rateLimiter.ts b/server/middlewares/rateLimiter.ts new file mode 100644 index 000000000..7ea29ff33 --- /dev/null +++ b/server/middlewares/rateLimiter.ts @@ -0,0 +1,60 @@ +import { Context, Next } from "koa"; +import { defaults } from "lodash"; +import RateLimiter from "@server/RateLimiter"; +import env from "@server/env"; +import { RateLimitExceededError } from "@server/errors"; +import Redis from "@server/redis"; +import { RateLimiterConfig } from "@server/types"; + +export function rateLimiter() { + return async function rateLimiterMiddleware(ctx: Context, next: Next) { + if (!env.RATE_LIMITER_ENABLED) { + return next(); + } + + const key = RateLimiter.hasRateLimiter(ctx.path) + ? `${ctx.path}:${ctx.ip}` + : `${ctx.ip}`; + const limiter = RateLimiter.getRateLimiter(ctx.path); + + try { + await limiter.consume(key); + } catch (rateLimiterRes) { + ctx.set("Retry-After", `${rateLimiterRes.msBeforeNext / 1000}`); + ctx.set("RateLimit-Limit", `${limiter.points}`); + ctx.set("RateLimit-Remaining", `${rateLimiterRes.remainingPoints}`); + ctx.set( + "RateLimit-Reset", + `${new Date(Date.now() + rateLimiterRes.msBeforeNext)}` + ); + + throw RateLimitExceededError(); + } + + return next(); + }; +} + +export function registerRateLimiter(config: RateLimiterConfig) { + return async function registerRateLimiterMiddleware( + ctx: Context, + next: Next + ) { + if (!env.RATE_LIMITER_ENABLED) { + return next(); + } + + if (!RateLimiter.hasRateLimiter(ctx.path)) { + RateLimiter.setRateLimiter( + ctx.path, + defaults(config, { + duration: env.RATE_LIMITER_DURATION_WINDOW, + keyPrefix: RateLimiter.RATE_LIMITER_REDIS_KEY_PREFIX, + storeClient: Redis.defaultClient, + }) + ); + } + + return next(); + }; +} diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 1e15be118..640a65016 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -5,6 +5,7 @@ import env from "@server/env"; import { NotFoundError } from "@server/errors"; import errorHandling from "@server/middlewares/errorHandling"; import methodOverride from "@server/middlewares/methodOverride"; +import { rateLimiter } from "@server/middlewares/rateLimiter"; import apiKeys from "./apiKeys"; import attachments from "./attachments"; import auth from "./auth"; @@ -80,6 +81,8 @@ router.post("*", (ctx) => { ctx.throw(NotFoundError("Endpoint not found")); }); +api.use(rateLimiter()); + // Router is embedded in a Koa application wrapper, because koa-router does not // allow middleware to catch any routes which were not explicitly defined. api.use(router.routes()); diff --git a/server/types.ts b/server/types.ts index eb40f2f8c..5924e947c 100644 --- a/server/types.ts +++ b/server/types.ts @@ -1,4 +1,5 @@ import { Context } from "koa"; +import Redis from "@server/redis"; import { FileOperation, Team, User } from "./models"; export enum AuthenticationTypes { @@ -297,3 +298,9 @@ export type Event = | UserEvent | ViewEvent | WebhookSubscriptionEvent; + +export type RateLimiterConfig = { + points: number; + duration: number; + storeClient: Redis; +}; diff --git a/yarn.lock b/yarn.lock index f44c086f3..f6dd96f15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12287,6 +12287,11 @@ range-parser@^1.0.3: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +rate-limiter-flexible@^2.3.7: + version "2.3.7" + resolved "https://registry.yarnpkg.com/rate-limiter-flexible/-/rate-limiter-flexible-2.3.7.tgz#c23e1f818a1575f1de1fd173437f4072125e1615" + integrity sha512-dmc+J/IffVBvHlqq5/XClsdLdkOdQV/tjrz00cwneHUbEDYVrf4aUDAyR4Jybcf2+Vpn4NwoVrnnAyt/D0ciWw== + raw-body@^2.2.0: version "2.4.1" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"