From 526cef3fda59490752b0f27022cecb664de37f4b Mon Sep 17 00:00:00 2001 From: Chris Brame Date: Mon, 16 May 2022 13:51:58 -0400 Subject: [PATCH] fix(login): basic rate limit protection --- package.json | 3 +- src/controllers/main.js | 80 +++++++++++++++++++-------- src/passport/index.js | 6 +- src/routes/index.js | 5 ++ src/views/429.hbs | 120 ++++++++++++++++++++++++++++++++++++++++ yarn.lock | 22 ++++---- 6 files changed, 200 insertions(+), 36 deletions(-) create mode 100644 src/views/429.hbs diff --git a/package.json b/package.json index 7faeab95f..fdb2acb63 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "mongoose": "6.2.2", "mongoose-autopopulate": "0.16.0", "mongoose-lean-virtuals": "0.9.0", - "nconf": "0.11.3", + "nconf": "0.12.0", "netmask": "2.0.2", "node-cache": "5.1.2", "node-sass": "7.0.1", @@ -87,6 +87,7 @@ "piexifjs": "1.0.6", "pm2": "5.1.2", "prop-types": "15.8.1", + "rate-limiter-flexible": "2.3.7", "react": "17.0.2", "react-colorful": "5.5.1", "react-dom": "17.0.2", diff --git a/src/controllers/main.js b/src/controllers/main.js index a2ea5a45a..979bd3ad6 100644 --- a/src/controllers/main.js +++ b/src/controllers/main.js @@ -19,6 +19,14 @@ const passport = require('passport') const winston = require('winston') const pkg = require('../../package') const xss = require('xss') +const RateLimiterMemory = require('rate-limiter-flexible').RateLimiterMemory + +const limiterSlowBruteByIP = new RateLimiterMemory({ + keyPrefix: 'login_fail_ip_per_day', + points: 15, + duration: 60 * 60 * 24, + blockDuration: 60 * 60 +}) const mainController = {} @@ -97,34 +105,62 @@ mainController.dashboard = function (req, res) { return res.render('dashboard', content) } -mainController.loginPost = function (req, res, next) { - passport.authenticate('local', function (err, user) { - if (err) { - winston.error(err) - return next(err) - } - if (!user) return res.redirect('/') - - let redirectUrl = '/dashboard' +mainController.loginPost = async function (req, res, next) { + const ipAddress = req.ip + const [resEmailAndIP] = await Promise.all([limiterSlowBruteByIP.get(ipAddress)]) - if (req.session.redirectUrl) { - redirectUrl = req.session.redirectUrl - req.session.redirectUrl = null - } - - if (req.user.role === 'user') { - redirectUrl = '/tickets' - } + let retrySecs = 0 + if (resEmailAndIP !== null && resEmailAndIP.consumedPoints > 2) { + retrySecs = Math.round(resEmailAndIP.msBeforeNext / 1000) || 1 + } - req.logIn(user, function (err) { + if (retrySecs > 0) { + res.set('Retry-After', retrySecs.toString()) + // res.status(429).send(`Too many requests. Retry after ${retrySecs} seconds.`) + res.status(429).render('429', { timeout: retrySecs.toString(), layout: false }) + } else { + passport.authenticate('local', async function (err, user) { if (err) { - winston.debug(err) + winston.error(err) return next(err) } + if (!user) { + try { + await limiterSlowBruteByIP.consume(ipAddress) + return res.redirect('/') + } catch (rlRejected) { + if (rlRejected instanceof Error) throw rlRejected + else { + const timeout = String(Math.round(rlRejected.msBeforeNext / 1000)) || 1 + res.set('Retry-After', timeout) + res.status(429).render('429', { timeout, layout: false }) + } + } + } - return res.redirect(redirectUrl) - }) - })(req, res, next) + if (user) { + let redirectUrl = '/dashboard' + + if (req.session.redirectUrl) { + redirectUrl = req.session.redirectUrl + req.session.redirectUrl = null + } + + if (req.user.role === 'user') { + redirectUrl = '/tickets' + } + + req.logIn(user, function (err) { + if (err) { + winston.debug(err) + return next(err) + } + + return res.redirect(redirectUrl) + }) + } + })(req, res, next) + } } mainController.l2AuthPost = function (req, res, next) { diff --git a/src/passport/index.js b/src/passport/index.js index 0bc260cf3..90c799e5e 100644 --- a/src/passport/index.js +++ b/src/passport/index.js @@ -49,11 +49,13 @@ module.exports = function () { } if (!user || user.deleted) { - return done(null, false, req.flash('loginMessage', 'No User Found.')) + req.flash('loginMessage', '') + return done(null, false, req.flash('loginMessage', 'Invalid Username/Password')) } if (!User.validate(password, user.password)) { - return done(null, false, req.flash('loginMessage', 'Incorrect Password.')) + req.flash('loginMessage', '') + return done(null, false, req.flash('loginMessage', 'Invalid Username/Password')) } req.user = user diff --git a/src/routes/index.js b/src/routes/index.js index a647dcbde..842de7d61 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -455,6 +455,11 @@ function handleErrors (err, req, res) { const status = err.status || 500 res.status(err.status) + if (status === 429) { + res.render('429', { layout: false }) + return + } + if (status === 500) { res.render('500', { layout: false }) return diff --git a/src/views/429.hbs b/src/views/429.hbs new file mode 100644 index 000000000..0a6b838ac --- /dev/null +++ b/src/views/429.hbs @@ -0,0 +1,120 @@ + + + + Trudesk + + + + +Trudesk Logo +
+

429

+

Too many requests.
Please wait. ({{timeout}})

+
+ + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4f8a6c486..c205806f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3144,16 +3144,11 @@ async@1.4.2: resolved "https://registry.yarnpkg.com/async/-/async-1.4.2.tgz#6c9edcb11ced4f0dd2f2d40db0d49a109c088aab" integrity sha1-bJ7csRztTw3S8tQNsNSaEJwIiqs= -async@3.2.3, async@^3.2.3: +async@3.2.3, async@^3.0.0, async@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== -async@^1.4.0: - version "1.5.2" - resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" - integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= - async@^2.6.0: version "2.6.1" resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" @@ -10583,12 +10578,12 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -nconf@0.11.3: - version "0.11.3" - resolved "https://registry.yarnpkg.com/nconf/-/nconf-0.11.3.tgz#4ee545019c53f1037ca57d696836feede3c49163" - integrity sha512-iYsAuDS9pzjVMGIzJrGE0Vk3Eh8r/suJanRAnWGBd29rVS2XtSgzcAo5l6asV3e4hH2idVONHirg1efoBOslBg== +nconf@0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/nconf/-/nconf-0.12.0.tgz#9cf70757aae4d440d43ed53c42f87da18471b8bf" + integrity sha512-T3fZPw3c7Dfrz8JBQEbEcZJ2s8f7cUMpKuyBtsGQe0b71pcXx6gNh4oti2xh5dxB+gO9ufNfISBlGvvWtfyMcA== dependencies: - async "^1.4.0" + async "^3.0.0" ini "^2.0.0" secure-keys "^1.0.0" yargs "^16.1.1" @@ -12546,6 +12541,11 @@ range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" 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.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.3.tgz#8f80305d11c2a0a545c2d9d89d7a0286fcead43c"