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
This commit is contained in:
@@ -170,3 +170,10 @@ SMTP_SECURE=true
|
|||||||
# The default interface language. See translate.getoutline.com for a list of
|
# The default interface language. See translate.getoutline.com for a list of
|
||||||
# available language codes and their rough percentage translated.
|
# available language codes and their rough percentage translated.
|
||||||
DEFAULT_LANGUAGE=en_US
|
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
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"query-string": "^7.0.1",
|
"query-string": "^7.0.1",
|
||||||
"quoted-printable": "^1.0.1",
|
"quoted-printable": "^1.0.1",
|
||||||
"randomstring": "1.1.5",
|
"randomstring": "1.1.5",
|
||||||
|
"rate-limiter-flexible": "^2.3.7",
|
||||||
"raw-loader": "^0.5.1",
|
"raw-loader": "^0.5.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-avatar-editor": "^11.1.0",
|
"react-avatar-editor": "^11.1.0",
|
||||||
|
|||||||
33
server/RateLimiter.ts
Normal file
33
server/RateLimiter.ts
Normal file
@@ -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<string, RateLimiterRedis>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -495,6 +495,37 @@ export class Environment {
|
|||||||
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION
|
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) {
|
private toOptionalString(value: string | undefined) {
|
||||||
return value ? value : undefined;
|
return value ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ async function start(id: number, disconnect: () => void) {
|
|||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(normalizedPortFlag || env.PORT || "3000");
|
server.listen(normalizedPortFlag || env.PORT || "3000");
|
||||||
process.once("SIGTERM", shutdown);
|
process.once("SIGTERM", shutdown);
|
||||||
process.once("SIGINT", shutdown);
|
process.once("SIGINT", shutdown);
|
||||||
|
|||||||
60
server/middlewares/rateLimiter.ts
Normal file
60
server/middlewares/rateLimiter.ts
Normal file
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import env from "@server/env";
|
|||||||
import { NotFoundError } from "@server/errors";
|
import { NotFoundError } from "@server/errors";
|
||||||
import errorHandling from "@server/middlewares/errorHandling";
|
import errorHandling from "@server/middlewares/errorHandling";
|
||||||
import methodOverride from "@server/middlewares/methodOverride";
|
import methodOverride from "@server/middlewares/methodOverride";
|
||||||
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||||
import apiKeys from "./apiKeys";
|
import apiKeys from "./apiKeys";
|
||||||
import attachments from "./attachments";
|
import attachments from "./attachments";
|
||||||
import auth from "./auth";
|
import auth from "./auth";
|
||||||
@@ -80,6 +81,8 @@ router.post("*", (ctx) => {
|
|||||||
ctx.throw(NotFoundError("Endpoint not found"));
|
ctx.throw(NotFoundError("Endpoint not found"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.use(rateLimiter());
|
||||||
|
|
||||||
// Router is embedded in a Koa application wrapper, because koa-router does not
|
// Router is embedded in a Koa application wrapper, because koa-router does not
|
||||||
// allow middleware to catch any routes which were not explicitly defined.
|
// allow middleware to catch any routes which were not explicitly defined.
|
||||||
api.use(router.routes());
|
api.use(router.routes());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Context } from "koa";
|
import { Context } from "koa";
|
||||||
|
import Redis from "@server/redis";
|
||||||
import { FileOperation, Team, User } from "./models";
|
import { FileOperation, Team, User } from "./models";
|
||||||
|
|
||||||
export enum AuthenticationTypes {
|
export enum AuthenticationTypes {
|
||||||
@@ -297,3 +298,9 @@ export type Event =
|
|||||||
| UserEvent
|
| UserEvent
|
||||||
| ViewEvent
|
| ViewEvent
|
||||||
| WebhookSubscriptionEvent;
|
| WebhookSubscriptionEvent;
|
||||||
|
|
||||||
|
export type RateLimiterConfig = {
|
||||||
|
points: number;
|
||||||
|
duration: number;
|
||||||
|
storeClient: Redis;
|
||||||
|
};
|
||||||
|
|||||||
@@ -12287,6 +12287,11 @@ range-parser@^1.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
|
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
|
||||||
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
|
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:
|
raw-body@^2.2.0:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
|
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
|
||||||
|
|||||||
Reference in New Issue
Block a user