diff --git a/server/src/server/middleware/rate-limiter.ts b/server/src/server/middleware/rate-limiter.ts index 185aecbbf..7109dcd54 100644 --- a/server/src/server/middleware/rate-limiter.ts +++ b/server/src/server/middleware/rate-limiter.ts @@ -1,8 +1,10 @@ import rateLimit from "express-rate-limit"; import { RedisClient } from "external/redis/redis"; +import { ONE_MINUTE } from "lib/constants/time"; import CreateLogCtx from "lib/logger/logger"; import { Environment, ServerConfig, TachiConfig } from "lib/setup/config"; import RateLimitRedis from "rate-limit-redis"; +import { integer } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -13,12 +15,13 @@ const store = export function ClearTestingRateLimitCache() { // ??? - RateLimitMiddleware.resetKey(`::ffff:127.0.0.1`); + NormalRateLimitMiddleware.resetKey(`::ffff:127.0.0.1`); + AggressiveRateLimitMiddleware.resetKey(`::ffff:127.0.0.1`); + HyperAggressiveRateLimitMiddleware.resetKey(`::ffff:127.0.0.1`); } -// 100 requests / minute is the current cap -export const RateLimitMiddleware = rateLimit({ - max: ServerConfig.RATE_LIMIT, +const CreateRateLimitOptions = (max: integer, windowMs?: number): rateLimit.Options => ({ + max, onLimitReached: (req) => { logger.warn(`User ${req.ip} hit rate limit.`, { url: req.url, @@ -32,6 +35,17 @@ export const RateLimitMiddleware = rateLimit({ description: `You have exceeded ${ServerConfig.RATE_LIMIT} requests per minute. Please wait.`, status: 429, message: "You're being rate limited.", - // eslint-disable-next-line @typescript-eslint/no-explicit-any }, + windowMs, }); + +// 100 requests / minute is the current cap +export const NormalRateLimitMiddleware = rateLimit(CreateRateLimitOptions(ServerConfig.RATE_LIMIT)); + +// 15 requests every 10 minutes. +export const AggressiveRateLimitMiddleware = rateLimit(CreateRateLimitOptions(10, ONE_MINUTE * 10)); + +// 2 requests every 20 minutes. +export const HyperAggressiveRateLimitMiddleware = rateLimit( + CreateRateLimitOptions(2, ONE_MINUTE * 20) +); diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index 925c24a77..2b962104a 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -6,6 +6,10 @@ import CreateLogCtx from "lib/logger/logger"; import { Environment, ServerConfig } from "lib/setup/config"; import Prudence from "prudence"; import prValidate from "server/middleware/prudence-validate"; +import { + AggressiveRateLimitMiddleware, + HyperAggressiveRateLimitMiddleware, +} from "server/middleware/rate-limiter"; import { integer } from "tachi-common"; import { DecrementCounterValue, GetNextCounterValue } from "utils/db"; import { Random20Hex } from "utils/misc"; @@ -40,6 +44,7 @@ const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u; */ router.post( "/login", + AggressiveRateLimitMiddleware, prValidate( { username: Prudence.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), @@ -159,6 +164,7 @@ router.post( */ router.post( "/register", + AggressiveRateLimitMiddleware, prValidate( { username: Prudence.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), @@ -324,6 +330,7 @@ router.post( */ router.post( "/verify-email", + AggressiveRateLimitMiddleware, prValidate({ code: "string", }), @@ -359,37 +366,42 @@ router.post( * * @name POST /api/v1/auth/resend-verify-email */ -router.post("/resend-verify-email", prValidate({ email: "string" }), async (req, res) => { - // Immediately send a response so the existence of emails - // cannot be timing attacked out. - res.status(200).json({ - success: true, - description: `Sent an email if the email address has not been verified.`, - body: {}, - }); +router.post( + "/resend-verify-email", + HyperAggressiveRateLimitMiddleware, + prValidate({ email: "string" }), + async (req, res) => { + // Immediately send a response so the existence of emails + // cannot be timing attacked out. + res.status(200).json({ + success: true, + description: `Sent an email if the email address has not been verified.`, + body: {}, + }); - const verifyInfo = await db["verify-email-codes"].findOne({ email: req.body.email }); + const verifyInfo = await db["verify-email-codes"].findOne({ email: req.body.email }); - if (!verifyInfo) { - logger.warn( - `Attempted to send reset email to ${req.body.email}, but no verifyInfo was set for them.` - ); - return; + if (!verifyInfo) { + logger.warn( + `Attempted to send reset email to ${req.body.email}, but no verifyInfo was set for them.` + ); + return; + } + + const user = await GetUserWithID(verifyInfo.userID); + + if (!user) { + logger.severe(`Email verifyInfo belongs to user that no longer exists?`, verifyInfo); + return; + } + + // Send the email again. + + const { text, html } = EmailFormatVerifyEmail(user!.username, verifyInfo.code); + + SendEmail(req.body.email, "Email Verification", html, text); } - - const user = await GetUserWithID(verifyInfo.userID); - - if (!user) { - logger.severe(`Email verifyInfo belongs to user that no longer exists?`, verifyInfo); - return; - } - - // Send the email again. - - const { text, html } = EmailFormatVerifyEmail(user!.username, verifyInfo.code); - - SendEmail(req.body.email, "Email Verification", html, text); -}); +); /** * Logs out the requesting user. @@ -420,53 +432,60 @@ router.post("/logout", (req, res) => { * * @name POST /api/v1/auth/forgot-password */ -router.post("/forgot-password", prValidate({ email: "string" }), async (req, res) => { - if (!ServerConfig.EMAIL_CONFIG && Environment.nodeEnv !== "test") { - return res.status(501).json({ - success: false, - description: `This server does not support password resets.`, - }); - } - - logger.debug(`Recieved password reset request for ${req.body.email}.`); - // For timing attack and infosec reasons, we can't do anything but **immediately** return here. - res.status(202).json({ - success: true, - description: "A code has been sent to your email.", - body: {}, - }); - - const userPrivateInfo = await db["user-private-information"].findOne({ email: req.body.email }); - - if (userPrivateInfo) { - const user = await db.users.findOne({ id: userPrivateInfo.userID }); - - if (!user) { - logger.severe( - `User ${userPrivateInfo.userID} has private information but no real account.` - ); - return; +router.post( + "/forgot-password", + HyperAggressiveRateLimitMiddleware, + prValidate({ email: "string" }), + async (req, res) => { + if (!ServerConfig.EMAIL_CONFIG && Environment.nodeEnv !== "test") { + return res.status(501).json({ + success: false, + description: `This server does not support password resets.`, + }); } - const code = `M${Random20Hex()}`; - - logger.verbose(`Created password reset code for ${FormatUserDoc(user)}.`); - - await db["password-reset-codes"].insert({ - code, - userID: user.id, - createdOn: Date.now(), + logger.debug(`Recieved password reset request for ${req.body.email}.`); + // For timing attack and infosec reasons, we can't do anything but **immediately** return here. + res.status(202).json({ + success: true, + description: "A code has been sent to your email.", + body: {}, }); - const { html, text } = EmailFormatResetPassword(user.username, code, req.ip); + const userPrivateInfo = await db["user-private-information"].findOne({ + email: req.body.email, + }); - SendEmail(userPrivateInfo.email, "Reset Password", html, text); - } else { - logger.info( - `Silently rejected password reset request for ${req.body.email}, as no user has this email.` - ); + if (userPrivateInfo) { + const user = await db.users.findOne({ id: userPrivateInfo.userID }); + + if (!user) { + logger.severe( + `User ${userPrivateInfo.userID} has private information but no real account.` + ); + return; + } + + const code = `M${Random20Hex()}`; + + logger.verbose(`Created password reset code for ${FormatUserDoc(user)}.`); + + await db["password-reset-codes"].insert({ + code, + userID: user.id, + createdOn: Date.now(), + }); + + const { html, text } = EmailFormatResetPassword(user.username, code, req.ip); + + SendEmail(userPrivateInfo.email, "Reset Password", html, text); + } else { + logger.info( + `Silently rejected password reset request for ${req.body.email}, as no user has this email.` + ); + } } -}); +); /** * Takes a code generated from /forgot-password, a new password, @@ -479,6 +498,7 @@ router.post("/forgot-password", prValidate({ email: "string" }), async (req, res */ router.post( "/reset-password", + AggressiveRateLimitMiddleware, prValidate({ code: "string", password: ValidatePassword, diff --git a/server/src/server/router/api/v1/router.ts b/server/src/server/router/api/v1/router.ts index 614c0a180..f205c625a 100644 --- a/server/src/server/router/api/v1/router.ts +++ b/server/src/server/router/api/v1/router.ts @@ -1,4 +1,8 @@ import { Router } from "express"; +import { + AggressiveRateLimitMiddleware, + NormalRateLimitMiddleware, +} from "server/middleware/rate-limiter"; import adminRouter from "./admin/router"; import authRouter from "./auth/router"; import clientsRouter from "./clients/router"; @@ -15,8 +19,14 @@ import usersRouter from "./users/router"; const router: Router = Router({ mergeParams: true }); -router.use("/admin", adminRouter); +// Auth is up here so it can have special rate limiting rules, +// since it needs slightly harsher ones! router.use("/auth", authRouter); + +// Everything else can use the normal rate limiter! +router.use(NormalRateLimitMiddleware); + +router.use("/admin", adminRouter); router.use("/status", statusRouter); router.use("/import", importRouter); router.use("/imports", importsRouter); diff --git a/server/src/server/router/router.ts b/server/src/server/router/router.ts index 0a130f9d2..10088693c 100644 --- a/server/src/server/router/router.ts +++ b/server/src/server/router/router.ts @@ -1,16 +1,13 @@ import { Router } from "express"; import { UpdateLastSeen } from "server/middleware/update-last-seen"; import { SetRequestPermissions } from "../middleware/auth"; -import { RateLimitMiddleware } from "../middleware/rate-limiter"; +import { NormalRateLimitMiddleware } from "../middleware/rate-limiter"; import apiRouterV1 from "./api/v1/router"; import irRouter from "./ir/router"; const router: Router = Router({ mergeParams: true }); -// Add APIAuth and RateLimiting -router.use(RateLimitMiddleware); - -router.use("/ir", irRouter); +router.use("/ir", NormalRateLimitMiddleware, irRouter); // request perms only apply to the api, IR may reuse this // but also may require custom authentication.