From 9da5e441be283bccb6366a850ad62ff1668a31ba Mon Sep 17 00:00:00 2001 From: Bruce Berrios <58147810+Bruception@users.noreply.github.com> Date: Tue, 28 Jun 2022 07:45:57 -0400 Subject: [PATCH] Add new rate limiting flow (#3230) Bruception * Add new rate limiting flow * Oops * Fix nit * Fix some bugs * Split key generation functions * Remove 429 * Change message for root limiter * Flag 429 and add config * Add status code config * Check enabled flag * Add custom status for ape keys * Bump coverage * swapped conditions around whats the point of checking if the status code is in the array if the whole thing is turned off anyway Co-authored-by: Miodec --- backend/jest.config.ts | 4 +- backend/package-lock.json | 11 + backend/package.json | 1 + backend/private/script.js | 8 +- backend/src/api/routes/ape-keys.ts | 8 +- backend/src/api/routes/configs.ts | 4 +- backend/src/api/routes/leaderboards.ts | 11 +- backend/src/api/routes/presets.ts | 8 +- backend/src/api/routes/psas.ts | 2 +- backend/src/api/routes/quotes.ts | 14 +- backend/src/api/routes/results.ts | 13 +- backend/src/api/routes/users.ts | 58 ++--- backend/src/app.ts | 7 + backend/src/constants/base-configuration.ts | 76 +++++- backend/src/constants/monkey-status-codes.ts | 5 + backend/src/middlewares/ape-rate-limit.ts | 40 ++- backend/src/middlewares/auth.ts | 2 +- backend/src/middlewares/error.ts | 5 +- backend/src/middlewares/rate-limit.ts | 260 ++++++++++++------- backend/src/types/types.d.ts | 8 +- backend/src/utils/error.ts | 2 +- 21 files changed, 368 insertions(+), 179 deletions(-) diff --git a/backend/jest.config.ts b/backend/jest.config.ts index abebbbc64..9b8ecc3f9 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -9,8 +9,8 @@ export default { // These percentages should never decrease statements: 38, branches: 38, - functions: 21, - lines: 41, + functions: 22, + lines: 42, }, }, }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 699569beb..d708b9be9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -29,6 +29,7 @@ "object-hash": "3.0.0", "path": "0.12.7", "prom-client": "14.0.1", + "rate-limiter-flexible": "2.3.7", "simple-git": "2.45.1", "string-similarity": "4.0.4", "swagger-stats": "0.99.2", @@ -7167,6 +7168,11 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.3.7.tgz", + "integrity": "sha512-dmc+J/IffVBvHlqq5/XClsdLdkOdQV/tjrz00cwneHUbEDYVrf4aUDAyR4Jybcf2+Vpn4NwoVrnnAyt/D0ciWw==" + }, "node_modules/raw-body": { "version": "2.4.0", "license": "MIT", @@ -13810,6 +13816,11 @@ "range-parser": { "version": "1.2.1" }, + "rate-limiter-flexible": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.3.7.tgz", + "integrity": "sha512-dmc+J/IffVBvHlqq5/XClsdLdkOdQV/tjrz00cwneHUbEDYVrf4aUDAyR4Jybcf2+Vpn4NwoVrnnAyt/D0ciWw==" + }, "raw-body": { "version": "2.4.0", "requires": { diff --git a/backend/package.json b/backend/package.json index 49f467ce8..e02bcf8bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,6 +36,7 @@ "object-hash": "3.0.0", "path": "0.12.7", "prom-client": "14.0.1", + "rate-limiter-flexible": "2.3.7", "simple-git": "2.45.1", "string-similarity": "4.0.4", "swagger-stats": "0.99.2", diff --git a/backend/private/script.js b/backend/private/script.js index e2fa1d77e..a3b7aca13 100644 --- a/backend/private/script.js +++ b/backend/private/script.js @@ -14,11 +14,13 @@ const buildNumberInput = (schema, parentState, key) => { input.classList.add("base-input"); input.type = "number"; input.value = parentState[key]; - input.min = schema.min || 0; + + const min = schema.min || 0; + input.min = min; input.addEventListener("change", () => { const normalizedValue = parseFloat(input.value, 10); - parentState[key] = normalizedValue; + parentState[key] = Math.max(normalizedValue, min); }); return input; @@ -164,7 +166,7 @@ const render = (state, schema) => { items, element, state, - `${currentKey}[${index}]`, + index, `${path}[${index}]` ); diff --git a/backend/src/api/routes/ape-keys.ts b/backend/src/api/routes/ape-keys.ts index 0f27a9619..d49f43291 100644 --- a/backend/src/api/routes/ape-keys.ts +++ b/backend/src/api/routes/ape-keys.ts @@ -40,16 +40,16 @@ router.use( router.get( "/", - RateLimit.apeKeysGet, authenticateRequest(), + RateLimit.apeKeysGet, checkIfUserCanManageApeKeys, asyncHandler(ApeKeyController.getApeKeys) ); router.post( "/", - RateLimit.apeKeysGenerate, authenticateRequest(), + RateLimit.apeKeysGenerate, checkIfUserCanManageApeKeys, validateRequest({ body: { @@ -62,8 +62,8 @@ router.post( router.patch( "/:apeKeyId", - RateLimit.apeKeysUpdate, authenticateRequest(), + RateLimit.apeKeysUpdate, checkIfUserCanManageApeKeys, validateRequest({ params: { @@ -79,8 +79,8 @@ router.patch( router.delete( "/:apeKeyId", - RateLimit.apeKeysDelete, authenticateRequest(), + RateLimit.apeKeysDelete, checkIfUserCanManageApeKeys, validateRequest({ params: { diff --git a/backend/src/api/routes/configs.ts b/backend/src/api/routes/configs.ts index 14214ba15..205885aef 100644 --- a/backend/src/api/routes/configs.ts +++ b/backend/src/api/routes/configs.ts @@ -9,15 +9,15 @@ const router = Router(); router.get( "/", - RateLimit.configGet, authenticateRequest(), + RateLimit.configGet, asyncHandler(ConfigController.getConfig) ); router.patch( "/", - RateLimit.configUpdate, authenticateRequest(), + RateLimit.configUpdate, validateRequest({ body: { config: configSchema.required(), diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index ca6e66013..8366e71d9 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -1,7 +1,7 @@ import joi from "joi"; import { Router } from "express"; import * as RateLimit from "../../middlewares/rate-limit"; -import apeRateLimit from "../../middlewares/ape-rate-limit"; +import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; import { authenticateRequest } from "../../middlewares/auth"; import * as LeaderboardController from "../controllers/leaderboard"; import { @@ -38,8 +38,8 @@ const requireDailyLeaderboardsEnabled = validateConfiguration({ router.get( "/", - RateLimit.leaderboardsGet, authenticateRequest({ isPublic: true, acceptApeKeys: true }), + withApeRateLimiter(RateLimit.leaderboardsGet), validateRequest({ query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT, }), @@ -48,9 +48,8 @@ router.get( router.get( "/rank", - RateLimit.leaderboardsGet, authenticateRequest({ acceptApeKeys: true }), - apeRateLimit, + withApeRateLimiter(RateLimit.leaderboardsGet), validateRequest({ query: BASE_LEADERBOARD_VALIDATION_SCHEMA, }), @@ -60,8 +59,8 @@ router.get( router.get( "/daily", requireDailyLeaderboardsEnabled, - RateLimit.leaderboardsGet, authenticateRequest({ isPublic: true }), + RateLimit.leaderboardsGet, validateRequest({ query: DAILY_LEADERBOARD_VALIDATION_SCHEMA, }), @@ -71,8 +70,8 @@ router.get( router.get( "/daily/rank", requireDailyLeaderboardsEnabled, - RateLimit.leaderboardsGet, authenticateRequest(), + RateLimit.leaderboardsGet, validateRequest({ query: DAILY_LEADERBOARD_VALIDATION_SCHEMA, }), diff --git a/backend/src/api/routes/presets.ts b/backend/src/api/routes/presets.ts index 5b64a9d5d..802c49924 100644 --- a/backend/src/api/routes/presets.ts +++ b/backend/src/api/routes/presets.ts @@ -20,15 +20,15 @@ const presetNameSchema = joi router.get( "/", - RateLimit.presetsGet, authenticateRequest(), + RateLimit.presetsGet, asyncHandler(PresetController.getPresets) ); router.post( "/", - RateLimit.presetsAdd, authenticateRequest(), + RateLimit.presetsAdd, validateRequest({ body: { name: presetNameSchema, @@ -42,8 +42,8 @@ router.post( router.patch( "/", - RateLimit.presetsEdit, authenticateRequest(), + RateLimit.presetsEdit, validateRequest({ body: { _id: joi.string().required(), @@ -60,8 +60,8 @@ router.patch( router.delete( "/:presetId", - RateLimit.presetsRemove, authenticateRequest(), + RateLimit.presetsRemove, validateRequest({ params: { presetId: joi.string().required(), diff --git a/backend/src/api/routes/psas.ts b/backend/src/api/routes/psas.ts index 307124e65..10a228949 100644 --- a/backend/src/api/routes/psas.ts +++ b/backend/src/api/routes/psas.ts @@ -1,7 +1,7 @@ +import { Router } from "express"; import * as PsaController from "../controllers/psa"; import * as RateLimit from "../../middlewares/rate-limit"; import { asyncHandler } from "../../middlewares/api-utils"; -import { Router } from "express"; const router = Router(); diff --git a/backend/src/api/routes/quotes.ts b/backend/src/api/routes/quotes.ts index 6f26f7e40..bd4fe5d3d 100644 --- a/backend/src/api/routes/quotes.ts +++ b/backend/src/api/routes/quotes.ts @@ -20,8 +20,8 @@ const checkIfUserIsQuoteMod = checkUserPermissions({ router.get( "/", - RateLimit.newQuotesGet, authenticateRequest(), + RateLimit.newQuotesGet, checkIfUserIsQuoteMod, asyncHandler(QuoteController.getQuotes) ); @@ -35,8 +35,8 @@ router.post( invalidMessage: "Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.", }), - RateLimit.newQuotesAdd, authenticateRequest(), + RateLimit.newQuotesAdd, validateRequest({ body: { text: joi.string().min(60).required(), @@ -51,8 +51,8 @@ router.post( router.post( "/approve", - RateLimit.newQuotesAction, authenticateRequest(), + RateLimit.newQuotesAction, validateRequest({ body: { quoteId: joi.string().required(), @@ -67,8 +67,8 @@ router.post( router.post( "/reject", - RateLimit.newQuotesAction, authenticateRequest(), + RateLimit.newQuotesAction, validateRequest({ body: { quoteId: joi.string().required(), @@ -80,8 +80,8 @@ router.post( router.get( "/rating", - RateLimit.quoteRatingsGet, authenticateRequest(), + RateLimit.quoteRatingsGet, validateRequest({ query: { quoteId: joi.string().regex(/^\d+$/).required(), @@ -93,8 +93,8 @@ router.get( router.post( "/rating", - RateLimit.quoteRatingsSubmit, authenticateRequest(), + RateLimit.quoteRatingsSubmit, validateRequest({ body: { quoteId: joi.number().required(), @@ -113,8 +113,8 @@ router.post( }, invalidMessage: "Quote reporting is unavailable.", }), - RateLimit.quoteReportSubmit, authenticateRequest(), + RateLimit.quoteReportSubmit, validateRequest({ body: { quoteId: joi.string().required(), diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 03f788e24..57845109f 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -9,14 +9,14 @@ import * as RateLimit from "../../middlewares/rate-limit"; import { Router } from "express"; import { authenticateRequest } from "../../middlewares/auth"; import joi from "joi"; -import apeRateLimit from "../../middlewares/ape-rate-limit"; +import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; const router = Router(); router.get( "/", - RateLimit.resultsGet, authenticateRequest(), + RateLimit.resultsGet, asyncHandler(ResultController.getResults) ); @@ -28,8 +28,8 @@ router.post( }, invalidMessage: "Results are not being saved at this time.", }), - RateLimit.resultsAdd, authenticateRequest(), + RateLimit.resultsAdd, validateRequest({ body: { result: resultSchema, @@ -40,8 +40,8 @@ router.post( router.patch( "/tags", - RateLimit.resultsTagsUpdate, authenticateRequest(), + RateLimit.resultsTagsUpdate, validateRequest({ body: { tagIds: joi.array().items(joi.string()).required(), @@ -53,18 +53,17 @@ router.patch( router.delete( "/", - RateLimit.resultsDeleteAll, authenticateRequest(), + RateLimit.resultsDeleteAll, asyncHandler(ResultController.deleteAll) ); router.get( "/last", - RateLimit.resultsGet, authenticateRequest({ acceptApeKeys: true, }), - apeRateLimit, + withApeRateLimiter(RateLimit.resultsGet), asyncHandler(ResultController.getLastResult) ); diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 37c1bd9a3..0ef624d75 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -8,7 +8,7 @@ import { validateConfiguration, } from "../../middlewares/api-utils"; import * as RateLimit from "../../middlewares/rate-limit"; -import apeRateLimit from "../../middlewares/ape-rate-limit"; +import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; import { containsProfanity, isUsernameValid } from "../../utils/validation"; import filterSchema from "../schemas/filter-schema"; @@ -82,15 +82,15 @@ const quoteIdSchema = joi.string().min(1).max(5).regex(/\d+/).required(); router.get( "/", - RateLimit.userGet, authenticateRequest(), + RateLimit.userGet, asyncHandler(UserController.getUser) ); router.post( "/signup", - RateLimit.userSignup, authenticateRequest(), + RateLimit.userSignup, validateRequest({ body: { email: joi.string().email(), @@ -114,15 +114,15 @@ router.get( router.delete( "/", - RateLimit.userDelete, authenticateRequest(), + RateLimit.userDelete, asyncHandler(UserController.deleteUser) ); router.patch( "/name", - RateLimit.userUpdateName, authenticateRequest(), + RateLimit.userUpdateName, validateRequest({ body: { name: usernameValidation, @@ -133,8 +133,8 @@ router.patch( router.patch( "/leaderboardMemory", - RateLimit.userUpdateLBMemory, authenticateRequest(), + RateLimit.userUpdateLBMemory, validateRequest({ body: { mode: joi @@ -151,8 +151,8 @@ router.patch( router.patch( "/email", - RateLimit.userUpdateEmail, authenticateRequest(), + RateLimit.userUpdateEmail, validateRequest({ body: { newEmail: joi.string().email().required(), @@ -164,8 +164,8 @@ router.patch( router.delete( "/personalBests", - RateLimit.userClearPB, authenticateRequest(), + RateLimit.userClearPB, asyncHandler(UserController.clearPb) ); @@ -178,9 +178,9 @@ const requireFilterPresetsEnabled = validateConfiguration({ router.post( "/resultFilterPresets", - RateLimit.userCustomFilterAdd, requireFilterPresetsEnabled, authenticateRequest(), + RateLimit.userCustomFilterAdd, validateRequest({ body: filterSchema, }), @@ -189,9 +189,9 @@ router.post( router.delete( "/resultFilterPresets/:presetId", - RateLimit.userCustomFilterRemove, requireFilterPresetsEnabled, authenticateRequest(), + RateLimit.userCustomFilterRemove, validateRequest({ params: { presetId: joi.string().required(), @@ -202,15 +202,15 @@ router.delete( router.get( "/tags", - RateLimit.userTagsGet, authenticateRequest(), + RateLimit.userTagsGet, asyncHandler(UserController.getTags) ); router.post( "/tags", - RateLimit.userTagsAdd, authenticateRequest(), + RateLimit.userTagsAdd, validateRequest({ body: { tagName: tagNameValidation, @@ -221,8 +221,8 @@ router.post( router.patch( "/tags", - RateLimit.userTagsEdit, authenticateRequest(), + RateLimit.userTagsEdit, validateRequest({ body: { tagId: joi.string().required(), @@ -234,8 +234,8 @@ router.patch( router.delete( "/tags/:tagId", - RateLimit.userTagsRemove, authenticateRequest(), + RateLimit.userTagsRemove, validateRequest({ params: { tagId: joi.string().required(), @@ -246,8 +246,8 @@ router.delete( router.delete( "/tags/:tagId/personalBest", - RateLimit.userTagsClearPB, authenticateRequest(), + RateLimit.userTagsClearPB, validateRequest({ params: { tagId: joi.string().required(), @@ -258,15 +258,15 @@ router.delete( router.get( "/customThemes", - RateLimit.userCustomThemeGet, authenticateRequest(), + RateLimit.userCustomThemeGet, asyncHandler(UserController.getCustomThemes) ); router.post( "/customThemes", - RateLimit.userCustomThemeAdd, authenticateRequest(), + RateLimit.userCustomThemeAdd, validateRequest({ body: { name: customThemeNameValidation, @@ -278,8 +278,8 @@ router.post( router.delete( "/customThemes", - RateLimit.userCustomThemeRemove, authenticateRequest(), + RateLimit.userCustomThemeRemove, validateRequest({ body: { themeId: customThemeIdValidation, @@ -290,8 +290,8 @@ router.delete( router.patch( "/customThemes", - RateLimit.userCustomThemeEdit, authenticateRequest(), + RateLimit.userCustomThemeEdit, validateRequest({ body: { themeId: customThemeIdValidation, @@ -313,9 +313,9 @@ const requireDiscordIntegrationEnabled = validateConfiguration({ router.post( "/discord/link", - RateLimit.userDiscordLink, requireDiscordIntegrationEnabled, authenticateRequest(), + RateLimit.userDiscordLink, validateRequest({ body: { tokenType: joi.string().required(), @@ -327,18 +327,17 @@ router.post( router.post( "/discord/unlink", - RateLimit.userDiscordUnlink, authenticateRequest(), + RateLimit.userDiscordUnlink, asyncHandler(UserController.unlinkDiscord) ); router.get( "/personalBests", - RateLimit.userGet, authenticateRequest({ acceptApeKeys: true, }), - apeRateLimit, + withApeRateLimiter(RateLimit.userGet), validateRequest({ query: { mode: joi.string().required(), @@ -350,25 +349,24 @@ router.get( router.get( "/stats", - RateLimit.userGet, authenticateRequest({ acceptApeKeys: true, }), - apeRateLimit, + withApeRateLimiter(RateLimit.userGet), asyncHandler(UserController.getStats) ); router.get( "/favoriteQuotes", - RateLimit.quoteFavoriteGet, authenticateRequest(), + RateLimit.quoteFavoriteGet, asyncHandler(UserController.getFavoriteQuotes) ); router.post( "/favoriteQuotes", - RateLimit.quoteFavoritePost, authenticateRequest(), + RateLimit.quoteFavoritePost, validateRequest({ body: { language: languageSchema, @@ -380,8 +378,8 @@ router.post( router.delete( "/favoriteQuotes", - RateLimit.quoteFavoriteDelete, authenticateRequest(), + RateLimit.quoteFavoriteDelete, validateRequest({ body: { language: languageSchema, @@ -400,11 +398,11 @@ const requireProfilesEnabled = validateConfiguration({ router.get( "/:uid/profile", - RateLimit.userProfileGet, requireProfilesEnabled, authenticateRequest({ isPublic: true, }), + RateLimit.userProfileGet, validateRequest({ params: { uid: joi.string().required(), @@ -427,9 +425,9 @@ const profileDetailsBase = joi router.patch( "/profile", - RateLimit.userProfileUpdate, requireProfilesEnabled, authenticateRequest(), + RateLimit.userProfileUpdate, validateRequest({ body: { bio: profileDetailsBase.max(150), diff --git a/backend/src/app.ts b/backend/src/app.ts index e75641bd8..2a5470908 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,10 @@ import addApiRoutes from "./api/routes"; import express, { urlencoded, json } from "express"; import contextMiddleware from "./middlewares/context"; import errorHandlingMiddleware from "./middlewares/error"; +import { + badAuthRateLimiterHandler, + rootRateLimiter, +} from "./middlewares/rate-limit"; function buildApp(): express.Application { const app = express(); @@ -17,6 +21,9 @@ function buildApp(): express.Application { app.use(contextMiddleware); + app.use(badAuthRateLimiterHandler); + app.use(rootRateLimiter); + addApiRoutes(app); app.use(errorHandlingMiddleware); diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 79d982937..29a57b18d 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -42,7 +42,13 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { enabled: false, }, }, - + rateLimiting: { + badAuthentication: { + enabled: false, + penalty: 0, + flaggedStatusCodes: [], + }, + }, dailyLeaderboards: { enabled: false, maxResults: 0, @@ -54,7 +60,42 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { }, }; -export const CONFIGURATION_FORM_SCHEMA = { +interface BaseSchema { + type: string; + label?: string; +} + +interface NumberSchema extends BaseSchema { + type: "number"; + min?: number; +} + +interface BooleanSchema extends BaseSchema { + type: "boolean"; +} + +interface StringSchema extends BaseSchema { + type: "string"; +} + +interface ArraySchema extends BaseSchema { + type: "array"; + items: Schema; +} + +interface ObjectSchema extends BaseSchema { + type: "object"; + fields: Record; +} + +type Schema = + | ObjectSchema + | ArraySchema + | StringSchema + | NumberSchema + | BooleanSchema; + +export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { type: "object", label: "Server Configuration", fields: { @@ -198,7 +239,36 @@ export const CONFIGURATION_FORM_SCHEMA = { }, }, }, - + rateLimiting: { + type: "object", + label: "Rate Limiting", + fields: { + badAuthentication: { + type: "object", + label: "Bad Authentication Rate Limiter", + fields: { + enabled: { + type: "boolean", + label: "Enabled", + }, + penalty: { + type: "number", + label: "Penalty", + min: 0, + }, + flaggedStatusCodes: { + type: "array", + label: "Flagged Status Codes", + items: { + label: "Status Code", + type: "number", + min: 0, + }, + }, + }, + }, + }, + }, dailyLeaderboards: { type: "object", label: "Daily Leaderboards", diff --git a/backend/src/constants/monkey-status-codes.ts b/backend/src/constants/monkey-status-codes.ts index e3e87c065..f8c405d24 100644 --- a/backend/src/constants/monkey-status-codes.ts +++ b/backend/src/constants/monkey-status-codes.ts @@ -16,6 +16,7 @@ interface Statuses { APE_KEY_INVALID: Status; APE_KEY_INACTIVE: Status; APE_KEY_MALFORMED: Status; + APE_KEY_RATE_LIMIT_EXCEEDED: Status; } const statuses: Statuses = { @@ -59,6 +60,10 @@ const statuses: Statuses = { code: 472, message: "ApeKey is malformed", }, + APE_KEY_RATE_LIMIT_EXCEEDED: { + code: 479, + message: "ApeKey rate limit exceeded", + }, }; const CUSTOM_STATUS_CODES = new Set( diff --git a/backend/src/middlewares/ape-rate-limit.ts b/backend/src/middlewares/ape-rate-limit.ts index 582cf3c5b..21ed771d5 100644 --- a/backend/src/middlewares/ape-rate-limit.ts +++ b/backend/src/middlewares/ape-rate-limit.ts @@ -1,31 +1,47 @@ -import { Response, NextFunction } from "express"; -import rateLimit, { Options } from "express-rate-limit"; import MonkeyError from "../utils/error"; +import { Response, NextFunction, RequestHandler } from "express"; +import statuses from "../constants/monkey-status-codes"; +import rateLimit, { + RateLimitRequestHandler, + Options, +} from "express-rate-limit"; -const REQUEST_MULTIPLIER = process.env.MODE === "dev" ? 100 : 1; +const REQUEST_MULTIPLIER = process.env.MODE === "dev" ? 1 : 1; const getKey = (req: MonkeyTypes.Request, _res: Response): string => { return req?.ctx?.decodedToken?.uid; }; -const customHandler = ( +const ONE_MINUTE = 1000 * 60; + +const { + APE_KEY_RATE_LIMIT_EXCEEDED: { message, code }, +} = statuses; + +export const customHandler = ( _req: MonkeyTypes.Request, _res: Response, _next: NextFunction, _options: Options ): void => { - throw new MonkeyError(429, "Too many attempts, please try again later."); + throw new MonkeyError(code, message); }; -const ONE_MINUTE = 1000 * 60; - -export default rateLimit({ +const apeRateLimiter = rateLimit({ windowMs: ONE_MINUTE, max: 30 * REQUEST_MULTIPLIER, keyGenerator: getKey, handler: customHandler, - skip: (req: MonkeyTypes.Request, _res) => { - const decodedToken = req?.ctx?.decodedToken; - return decodedToken?.type !== "ApeKey"; - }, }); + +export function withApeRateLimiter( + defaultRateLimiter: RateLimitRequestHandler +): RequestHandler { + return (req: MonkeyTypes.Request, res: Response, next: NextFunction) => { + if (req.ctx.decodedToken.type === "ApeKey") { + return apeRateLimiter(req, res, next); + } + + return defaultRateLimiter(req, res, next); + }; +} diff --git a/backend/src/middlewares/auth.ts b/backend/src/middlewares/auth.ts index af2828056..0f9922151 100644 --- a/backend/src/middlewares/auth.ts +++ b/backend/src/middlewares/auth.ts @@ -150,7 +150,7 @@ async function authenticateWithApeKey( options: RequestAuthenticationOptions ): Promise { if (!configuration.apeKeys.acceptKeys) { - throw new MonkeyError(403, "ApeKeys are not being accepted at this time"); + throw new MonkeyError(503, "ApeKeys are not being accepted at this time"); } if (!options.acceptApeKeys) { diff --git a/backend/src/middlewares/error.ts b/backend/src/middlewares/error.ts index b79e29fe6..0f077848e 100644 --- a/backend/src/middlewares/error.ts +++ b/backend/src/middlewares/error.ts @@ -2,8 +2,9 @@ import * as db from "../init/db"; import { v4 as uuidv4 } from "uuid"; import Logger from "../utils/logger"; import MonkeyError from "../utils/error"; -import { MonkeyResponse, handleMonkeyResponse } from "../utils/monkey-response"; +import { incrementBadAuth } from "./rate-limit"; import { NextFunction, Response } from "express"; +import { MonkeyResponse, handleMonkeyResponse } from "../utils/monkey-response"; async function errorHandlingMiddleware( error: Error, @@ -33,6 +34,8 @@ async function errorHandlingMiddleware( monkeyResponse.message = `Oops! Our monkeys dropped their bananas. Please try again later. - ${monkeyResponse.data.errorId}`; } + await incrementBadAuth(req, res, monkeyResponse.status); + if (process.env.MODE !== "dev" && monkeyResponse.status >= 500) { const { uid, errorId } = monkeyResponse.data; diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 42bd4486b..bd3ad9900 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -1,17 +1,25 @@ -import { Response, NextFunction } from "express"; -import rateLimit, { Options } from "express-rate-limit"; import MonkeyError from "../utils/error"; +import { Response, NextFunction } from "express"; +import { RateLimiterMemory } from "rate-limiter-flexible"; +import rateLimit, { Options } from "express-rate-limit"; const REQUEST_MULTIPLIER = process.env.MODE === "dev" ? 100 : 1; -const getAddress = (req: MonkeyTypes.Request, _res: Response): string => { +const getKey = (req: MonkeyTypes.Request, _res: Response): string => { return (req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || req.ip || "255.255.255.255") as string; }; -const customHandler = ( +const getKeyWithUid = (req: MonkeyTypes.Request, _res: Response): string => { + const uid = req?.ctx?.decodedToken?.uid; + const useUid = uid.length > 0 && uid; + + return (useUid || getKey(req, _res)) as string; +}; + +export const customHandler = ( _req: MonkeyTypes.Request, _res: Response, _next: NextFunction, @@ -20,65 +28,129 @@ const customHandler = ( throw new MonkeyError(429, "Too many attempts, please try again later."); }; -const ONE_HOUR = 1000 * 60 * 60; +const ONE_HOUR_SECONDS = 60 * 60; +const ONE_HOUR_MS = 1000 * ONE_HOUR_SECONDS; + +// Root Rate Limit +export const rootRateLimiter = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 2000 * REQUEST_MULTIPLIER, + keyGenerator: getKey, + handler: (_req, _res, _next, _options): void => { + throw new MonkeyError( + 429, + "Maximum API request limit reached. Please try again later." + ); + }, +}); + +// Bad Authentication Rate Limiter +const badAuthRateLimiter = new RateLimiterMemory({ + points: 30 * REQUEST_MULTIPLIER, + duration: ONE_HOUR_SECONDS, +}); + +export async function badAuthRateLimiterHandler( + req: MonkeyTypes.Request, + res: Response, + next: NextFunction +): Promise { + if (!req.ctx.configuration.rateLimiting.badAuthentication.enabled) { + return next(); + } + + try { + const key = getKey(req, res); + const rateLimitStatus = await badAuthRateLimiter.get(key); + + if (rateLimitStatus !== null && rateLimitStatus?.remainingPoints <= 0) { + throw new MonkeyError( + 429, + "Too many bad authentication attempts, please try again later." + ); + } + } catch (error) { + return next(error); + } + + next(); +} + +export async function incrementBadAuth( + req: MonkeyTypes.Request, + res: Response, + status: number +): Promise { + const { enabled, penalty, flaggedStatusCodes } = + req.ctx.configuration.rateLimiting.badAuthentication; + + if (!enabled || !flaggedStatusCodes.includes(status)) { + return; + } + + try { + const key = getKey(req, res); + await badAuthRateLimiter.penalty(key, penalty); + } catch (error) {} +} // Config Routing export const configUpdate = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const configGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 120 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); // Leaderboards Routing export const leaderboardsGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); // New Quotes Routing export const newQuotesGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const newQuotesAdd = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const newQuotesAction = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); // Quote Ratings Routing export const quoteRatingsGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const quoteRatingsSubmit = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); @@ -86,7 +158,7 @@ export const quoteRatingsSubmit = rateLimit({ export const quoteReportSubmit = rateLimit({ windowMs: 30 * 60 * 1000, // 30 min max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); @@ -94,50 +166,50 @@ export const quoteReportSubmit = rateLimit({ export const quoteFavoriteGet = rateLimit({ windowMs: 30 * 60 * 1000, // 30 min max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const quoteFavoritePost = rateLimit({ windowMs: 30 * 60 * 1000, // 30 min max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const quoteFavoriteDelete = rateLimit({ windowMs: 30 * 60 * 1000, // 30 min max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); // Presets Routing export const presetsGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const presetsAdd = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const presetsRemove = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const presetsEdit = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); @@ -145,229 +217,229 @@ export const presetsEdit = rateLimit({ export const psaGet = rateLimit({ windowMs: 60 * 1000, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); // Results Routing export const resultsGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const resultsAdd = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const resultsTagsUpdate = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 100 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const resultsDeleteAll = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 10 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const resultsLeaderboardGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const resultsLeaderboardQualificationGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); // Users Routing export const userGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userSignup = rateLimit({ - windowMs: 24 * ONE_HOUR, // 1 day + windowMs: 24 * ONE_HOUR_MS, // 1 day max: 3 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userDelete = rateLimit({ - windowMs: 24 * ONE_HOUR, // 1 day + windowMs: 24 * ONE_HOUR_MS, // 1 day max: 3 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userCheckName = rateLimit({ windowMs: 60 * 1000, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userUpdateName = rateLimit({ - windowMs: 24 * ONE_HOUR, // 1 day + windowMs: 24 * ONE_HOUR_MS, // 1 day max: 3 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userUpdateLBMemory = rateLimit({ windowMs: 60 * 1000, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userUpdateEmail = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userClearPB = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userCustomFilterAdd = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userCustomFilterRemove = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userTagsGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userTagsRemove = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userTagsClearPB = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userTagsEdit = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userTagsAdd = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userCustomThemeGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userCustomThemeAdd = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userCustomThemeRemove = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userCustomThemeEdit = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userDiscordLink = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 15 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const usersTagsEdit = userDiscordLink; export const userDiscordUnlink = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 15 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userProfileGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 100 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const userProfileUpdate = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); // ApeKeys Routing export const apeKeysGet = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 120 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); export const apeKeysGenerate = rateLimit({ - windowMs: ONE_HOUR, + windowMs: ONE_HOUR_MS, max: 15 * REQUEST_MULTIPLIER, - keyGenerator: getAddress, + keyGenerator: getKeyWithUid, handler: customHandler, }); diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index b0ad96a87..87b52ac0d 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -48,7 +48,13 @@ declare namespace MonkeyTypes { apeKeyBytes: number; apeKeySaltRounds: number; }; - + rateLimiting: { + badAuthentication: { + enabled: boolean; + penalty: number; + flaggedStatusCodes: number[]; + }; + }; dailyLeaderboards: { enabled: boolean; leaderboardExpirationTimeInDays: number; diff --git a/backend/src/utils/error.ts b/backend/src/utils/error.ts index 02747a370..4a0525330 100644 --- a/backend/src/utils/error.ts +++ b/backend/src/utils/error.ts @@ -5,7 +5,7 @@ class MonkeyError extends Error { errorId: string; uid?: string; - constructor(status: number, message: string, stack?: string, uid?: string) { + constructor(status: number, message?: string, stack?: string, uid?: string) { super(); this.status = status ?? 500; this.errorId = uuidv4();