From 44aac16991ccafa5b7ff932ec27c7669b1c5c58d Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Tue, 23 Apr 2024 19:13:06 -0400 Subject: [PATCH] Add ip rate limiting --- packages/backend/src/CoreModule.js | 3 + packages/backend/src/routers/change_email.js | 10 ++ packages/backend/src/routers/login.js | 7 +- packages/backend/src/routers/passwd.js | 5 + packages/backend/src/routers/save_account.js | 5 + .../backend/src/routers/send-confirm-email.js | 5 + .../src/routers/send-pass-recovery-email.js | 6 ++ .../src/routers/set-pass-using-token.js | 5 + packages/backend/src/routers/signup.js | 5 + .../abuse-prevention/EdgeRateLimitService.js | 95 +++++++++++++++++++ .../abuse-prevention/IdentificationService.js | 4 + 11 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js diff --git a/packages/backend/src/CoreModule.js b/packages/backend/src/CoreModule.js index 12be4349a..62f208713 100644 --- a/packages/backend/src/CoreModule.js +++ b/packages/backend/src/CoreModule.js @@ -192,6 +192,9 @@ const install = async ({ services, app }) => { const { SessionService } = require('./services/SessionService'); services.registerService('session', SessionService); + + const { EdgeRateLimitService } = require('./services/abuse-prevention/EdgeRateLimitService'); + services.registerService('edge-rate-limit', EdgeRateLimitService); } const install_legacy = async ({ services }) => { diff --git a/packages/backend/src/routers/change_email.js b/packages/backend/src/routers/change_email.js index 6e2a4c670..fd1d91392 100644 --- a/packages/backend/src/routers/change_email.js +++ b/packages/backend/src/routers/change_email.js @@ -51,6 +51,11 @@ const CHANGE_EMAIL_START = eggspress('/change_email/start', { key: 'new_email', expected: 'a valid email address' }); } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('change-email-start') ) { + return res.status(429).send('Too many requests.'); + } + // check if email is already in use const db = req.services.get('database').get(DB_WRITE, 'auth'); const rows = await db.read( @@ -93,6 +98,11 @@ const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', { throw APIError.create('field_missing', null, { key: 'token' }); } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('change-email-confirm') ) { + return res.status(429).send('Too many requests.'); + } + const { token, user_id } = jwt.verify(jwt_token, config.jwt_secret); const db = req.services.get('database').get(DB_WRITE, 'auth'); diff --git a/packages/backend/src/routers/login.js b/packages/backend/src/routers/login.js index 705b76259..ea638cb29 100644 --- a/packages/backend/src/routers/login.js +++ b/packages/backend/src/routers/login.js @@ -60,11 +60,10 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res else if(req.body.email && !validator.isEmail(req.body.email)) return res.status(400).send('Invalid email.') - // Increment & check rate limit - if(kv.incr(`login|${req.ip}|${req.body.email ?? req.body.username}`) > 10) + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('login') ) { return res.status(429).send('Too many requests.'); - // Set expiry for rate limit - kv.expire(`login|${req.ip}|${req.body.email ?? req.body.username}`, 60*10, 'NX') + } try{ let user; diff --git a/packages/backend/src/routers/passwd.js b/packages/backend/src/routers/passwd.js index 450c868f3..2649fe051 100644 --- a/packages/backend/src/routers/passwd.js +++ b/packages/backend/src/routers/passwd.js @@ -45,6 +45,11 @@ router.post('/passwd', auth, express.json(), async (req, res, next)=>{ else if (typeof req.body.new_pass !== 'string') return res.status(400).send('new_pass must be a string.') + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('passwd') ) { + return res.status(429).send('Too many requests.'); + } + try{ // check old_pass const isMatch = await bcrypt.compare(req.body.old_pass, req.user.password) diff --git a/packages/backend/src/routers/save_account.js b/packages/backend/src/routers/save_account.js index da3a39551..67876fbfb 100644 --- a/packages/backend/src/routers/save_account.js +++ b/packages/backend/src/routers/save_account.js @@ -70,6 +70,11 @@ router.post('/save_account', auth, express.json(), async (req, res, next)=>{ else if(req.body.password.length < config.min_pass_length) return res.status(400).send(`Password must be at least ${config.min_pass_length} characters long.`) + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('save-account') ) { + return res.status(429).send('Too many requests.'); + } + // duplicate username check, do this only if user has supplied a new username if(req.body.username !== req.user.username && await username_exists(req.body.username)) return res.status(400).send('This username already exists in our database. Please use another one.'); diff --git a/packages/backend/src/routers/send-confirm-email.js b/packages/backend/src/routers/send-confirm-email.js index 974b7884a..6cec1c57e 100644 --- a/packages/backend/src/routers/send-confirm-email.js +++ b/packages/backend/src/routers/send-confirm-email.js @@ -27,6 +27,11 @@ const { DB_WRITE } = require('../services/database/consts.js'); // POST /send-confirm-email // -----------------------------------------------------------------------// router.post('/send-confirm-email', auth, express.json(), async (req, res, next)=>{ + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('send-confirm-email') ) { + return res.status(429).send('Too many requests.'); + } + // check subdomain if(require('../helpers').subdomain(req) !== 'api') next(); diff --git a/packages/backend/src/routers/send-pass-recovery-email.js b/packages/backend/src/routers/send-pass-recovery-email.js index c078ddde7..f1fa8dd5d 100644 --- a/packages/backend/src/routers/send-pass-recovery-email.js +++ b/packages/backend/src/routers/send-pass-recovery-email.js @@ -51,6 +51,12 @@ router.post('/send-pass-recovery-email', express.json(), body_parser_error_handl else if(req.body.email && !validator.isEmail(req.body.email)) return res.status(400).send('Invalid email.') + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('send-pass-recovery-email') ) { + return res.status(429).send('Too many requests.'); + } + + try{ let user; // see if username exists diff --git a/packages/backend/src/routers/set-pass-using-token.js b/packages/backend/src/routers/set-pass-using-token.js index 860e1718a..f3df6d04a 100644 --- a/packages/backend/src/routers/set-pass-using-token.js +++ b/packages/backend/src/routers/set-pass-using-token.js @@ -52,6 +52,11 @@ router.post('/set-pass-using-token', express.json(), async (req, res, next)=>{ else if(req.body.password.length < config.min_pass_length) return res.status(400).send(`Password must be at least ${config.min_pass_length} characters long.`) + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('set-pass-using-token') ) { + return res.status(429).send('Too many requests.'); + } + try{ const info = await db.write( 'UPDATE user SET password=?, pass_recovery_token=NULL WHERE `uuid` = ? AND pass_recovery_token = ?', diff --git a/packages/backend/src/routers/signup.js b/packages/backend/src/routers/signup.js index 4d25e3791..70d1cfcd3 100644 --- a/packages/backend/src/routers/signup.js +++ b/packages/backend/src/routers/signup.js @@ -44,6 +44,11 @@ module.exports = eggspress(['/signup'], { if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '') next(); + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('signup') ) { + return res.status(429).send('Too many requests.'); + } + // modules const db = req.services.get('database').get(DB_WRITE, 'auth'); const bcrypt = require('bcrypt') diff --git a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js new file mode 100644 index 000000000..11f6db3d9 --- /dev/null +++ b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -0,0 +1,95 @@ +const { Context } = require("../../util/context"); +const { asyncSafeSetInterval } = require("../../util/promise"); + +const { MINUTE, HOUR } = require('../../util/time.js'); +const BaseService = require("../BaseService"); + +class EdgeRateLimitService extends BaseService { + _construct () { + this.scopes = { + ['login']: { + limit: 3, + window: 15 * MINUTE, + }, + ['signup']: { + limit: 10, + window: 15 * MINUTE, + }, + ['send-confirm-email']: { + limit: 10, + window: HOUR, + }, + ['send-pass-recovery-email']: { + limit: 10, + window: HOUR, + }, + ['set-pass-using-token']: { + limit: 10, + window: HOUR, + }, + ['save-account']: { + limit: 10, + window: HOUR, + }, + ['change-email-start']: { + limit: 10, + window: HOUR, + }, + ['change-email-confirm']: { + limit: 10, + window: HOUR, + }, + ['passwd']: { + limit: 10, + window: HOUR, + }, + }; + this.requests = new Map(); + } + + async _init () { + asyncSafeSetInterval(() => this.cleanup(), 5 * MINUTE); + } + + check (scope) { + const { window, limit } = this.scopes[scope]; + + const requester = Context.get('requester'); + const rl_identifier = requester.rl_identifier; + const key = `${scope}:${rl_identifier}`; + const now = Date.now(); + const windowStart = now - window; + + if (!this.requests.has(key)) { + this.requests.set(key, []); + } + + // Access the timestamps of past requests for this scope and IP + const timestamps = this.requests.get(key); + + // Remove timestamps that are outside the current window + while (timestamps.length > 0 && timestamps[0] < windowStart) { + timestamps.shift(); + } + + // Check if the current request exceeds the rate limit + if (timestamps.length >= limit) { + return false; + } else { + // Add current timestamp and allow the request + timestamps.push(now); + return true; + } + } + + cleanup() { + this.log.tick('edge rate-limit cleanup task'); + for (const [key, timestamps] of this.requests.entries()) { + if (timestamps.length === 0) { + this.requests.delete(key); + } + } + } +} + +module.exports = { EdgeRateLimitService }; diff --git a/packages/backend/src/services/abuse-prevention/IdentificationService.js b/packages/backend/src/services/abuse-prevention/IdentificationService.js index b2274cc40..4d2cd2204 100644 --- a/packages/backend/src/services/abuse-prevention/IdentificationService.js +++ b/packages/backend/src/services/abuse-prevention/IdentificationService.js @@ -68,6 +68,10 @@ class Requester { return puter_origins.includes(this.origin); } + get rl_identifier () { + return this.ip_forwarded || this.ip; + } + serialize () { return { ua: this.ua,