From 99d80da72a27ad2420490072067b6b277ab6b759 Mon Sep 17 00:00:00 2001 From: JandereDev Date: Sat, 5 Feb 2022 22:46:50 +0100 Subject: [PATCH] add shitty ratelimiting --- api/src/index.ts | 3 + api/src/middlewares/ratelimit.ts | 109 +++++++++++++++++++++++ api/src/routes/login.ts | 8 +- bot/src/bot/modules/api_communication.ts | 7 +- 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 api/src/middlewares/ratelimit.ts diff --git a/api/src/index.ts b/api/src/index.ts index c30ff2d..dfffa4e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -13,6 +13,7 @@ const logger: Log75 = new (Log75 as any).default(DEBUG ? LogLevel.Debug : LogLev const db = buildDBClient(); const app = Express(); +app.set('trust proxy', true); app.use(Express.json()); export { logger, app, db, PORT, SESSION_LIFETIME } @@ -22,6 +23,8 @@ export { logger, app, db, PORT, SESSION_LIFETIME } import('./middlewares/log'), import('./middlewares/updateTokenExpiry'), import('./middlewares/cors'), + import('./middlewares/ratelimit'), + import('./routes/internal/ws'), import('./routes/root'), import('./routes/login'), diff --git a/api/src/middlewares/ratelimit.ts b/api/src/middlewares/ratelimit.ts new file mode 100644 index 0000000..a3b4dff --- /dev/null +++ b/api/src/middlewares/ratelimit.ts @@ -0,0 +1,109 @@ +import { Request, Response } from "express"; +import { FindOneResult } from "monk"; +import { app, db, logger } from ".."; + +const ratelimits = db.get('ratelimits'); + +type RateLimitObject = { + ip: string, + requests: { route: string, time: number }[], + lastActivity: number, +} + +// Might use redis here later, idk +// I am also aware that there's better ways to do this + +class RateLimiter { + route: string; + limit: number; + timeframe: number; + + constructor(route: string, limits: { limit: number, timeframe: number }) { + this.route = route; + this.limit = limits.limit; + this.timeframe = limits.timeframe; + } + + async execute(req: Request, res: Response, next: () => void) { + try { + const ip = req.ip; + const now = Date.now(); + + const entry: FindOneResult = await ratelimits.findOne({ ip }); + if (!entry) { + logger.debug('Ratelimiter: Request from new IP address, creating new document'); + next(); + await ratelimits.insert({ + ip, + lastActivity: now, + requests: [{ route: this.route, time: now }], + }); + return; + } + + const reqs = entry.requests.filter( + r => r.route == this.route && r.time > now - (this.timeframe * 1000) + ); + + if (reqs.length >= this.limit) { + logger.debug(`Ratelimiter: IP address exceeded ratelimit for ${this.route} [${this.limit}/${this.timeframe}]`); + res + .status(429) + .send({ + error: 'You are being rate limited.', + limit: this.limit, + timeframe: this.timeframe, + }); + } else next(); + + // Can't put a $push and $pull into the same query + await Promise.all([ + ratelimits.update({ ip }, { + $push: { + requests: { route: this.route, time: now } + }, + $set: { + lastActivity: now + } + }), + ratelimits.update({ ip }, { + $pull: { + requests: { + route: this.route, + time: { + $lt: now - (this.timeframe * 1000) + } + } + } + }), + ]); + } catch(e) { console.error(e) } + } +} + +app.use('*', (...args) => (new RateLimiter('*', { limit: 20, timeframe: 1 })).execute(...args)); + +// Delete all documents where the last +// activity was more than 24 hours ago. +// This ensures that we don't store +// personally identifying data for longer +// than required. + +const cleanDocuments = async () => { + try { + logger.info('Ratelimiter: Deleting old documents'); + + const { deletedCount } = await ratelimits.remove({ + lastActivity: { $lt: Date.now() - 1000 * 60 * 60 * 24 } + }, { multi: true }); + + logger.done(`Ratelimiter: Deleted ${deletedCount ?? '??'} documents.`); + } catch(e) { + console.error(e); + } +} + +setTimeout(cleanDocuments, 1000 * 10); +setInterval(cleanDocuments, 10000 * 60 * 60); + +export { RateLimiter } diff --git a/api/src/routes/login.ts b/api/src/routes/login.ts index 03bd244..bb60002 100644 --- a/api/src/routes/login.ts +++ b/api/src/routes/login.ts @@ -5,6 +5,7 @@ import { botReq } from './internal/ws'; import { db } from '..'; import { FindOneResult } from 'monk'; import { badRequest } from '../utils'; +import { RateLimiter } from '../middlewares/ratelimit'; class BeginReqBody { user: string; @@ -15,7 +16,10 @@ class CompleteReqBody { code: string; } -app.post('/login/begin', async (req: Request, res: Response) => { +const beginRatelimiter = new RateLimiter('/login/begin', { limit: 10, timeframe: 300 }); +const completeRatelimiter = new RateLimiter('/login/complete', { limit: 5, timeframe: 30 }); + +app.post('/login/begin', (...args) => beginRatelimiter.execute(...args), async (req: Request, res: Response) => { const body = req.body as BeginReqBody; if (!body.user || typeof body.user != 'string') return badRequest(res); @@ -26,7 +30,7 @@ app.post('/login/begin', async (req: Request, res: Response) => { res.status(200).send({ success: true, nonce: r.nonce, code: r.code, uid: r.uid }); }); -app.post('/login/complete', async (req: Request, res: Response) => { +app.post('/login/complete', (...args) => completeRatelimiter.execute(...args), async (req: Request, res: Response) => { const body = req.body as CompleteReqBody; if ((!body.user || typeof body.user != 'string') || (!body.nonce || typeof body.nonce != 'string') || diff --git a/bot/src/bot/modules/api_communication.ts b/bot/src/bot/modules/api_communication.ts index 65e8522..c9fcc9c 100644 --- a/bot/src/bot/modules/api_communication.ts +++ b/bot/src/bot/modules/api_communication.ts @@ -111,8 +111,13 @@ wsEvents.on('req:requestLogin', async (data: any, cb: (data: WSResponse) => void const nonce = ulid(); - const previousLogins = await bot.db.get('pending_logins').find({ user: user._id, confirmed: true }); + const [previousLogins, currentValidLogins] = await Promise.all([ + bot.db.get('pending_logins').find({ user: user._id, confirmed: true }), + bot.db.get('pending_logins').find({ user: user._id, confirmed: false, expires: { $gt: Date.now() } }), + ]); + if (currentValidLogins.length >= 5) return cb({ success: false, statusCode: 403, error: 'Too many pending logins. Try again later.' }); + await bot.db.get('pending_logins').insert({ code, expires: Date.now() + (1000 * 60 * 15), // Expires in 15 minutes