[web] Make rate-limit on login consistent, prevent "trim/case bypass" (#19555)

* Replace `LoginRateLimiter.processLoginRequest` call by use of `RateLimiterMiddleware`

* Lowercase the email to avoid rate-limit bypass

* Remove unit test "when the users rate limit"

* Use `EmailHelper.parseEmail` to normalize email in `processLoginRequest`

This should address the `trim()` bypass

* Use `.trim().toLowerCase()` instead of `EmailHelper.parseEmail`

We can't use `EmailHelper.parseEmail`, else it breaks the test (and feature): "with username that does not look like an email"

* Add acceptance test for rate limit

* Add comment on rate limits

* Rename `rateLimiter` to `rateLimiterLoginEmail` for clarity

* Make the login rate limits configurable from the settings

GitOrigin-RevId: cf1c3a416745f2b007c85014a5084570d4a049a7
This commit is contained in:
Antoine Clausse 2024-07-29 10:40:36 +02:00 committed by Copybot
parent 7212c16dde
commit 5f2718cf29
8 changed files with 137 additions and 112 deletions

View file

@ -212,19 +212,6 @@ const AuthenticationController = {
if (info != null) { if (info != null) {
return done(null, false, info) return done(null, false, info)
} }
LoginRateLimiter.processLoginRequest(email, function (err, isAllowed) {
if (err) {
return done(err)
}
if (!isAllowed) {
logger.debug({ email }, 'too many login requests')
return done(null, null, {
text: req.i18n.translate('to_many_login_requests_2_mins'),
type: 'error',
key: 'to-many-login-requests-2-mins',
status: 429,
})
}
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
const auditLog = { const auditLog = {
ipAddress: req.ip, ipAddress: req.ip,
@ -296,7 +283,6 @@ const AuthenticationController = {
} }
} }
) )
})
} }
) )
}, },

View file

@ -1,14 +1,18 @@
const { RateLimiter } = require('../../infrastructure/RateLimiter') const { RateLimiter } = require('../../infrastructure/RateLimiter')
const { promisifyAll } = require('@overleaf/promise-utils') const { promisifyAll } = require('@overleaf/promise-utils')
const Settings = require('@overleaf/settings')
const rateLimiter = new RateLimiter('login', { const rateLimiterLoginEmail = new RateLimiter(
'login',
Settings.rateLimit?.login?.email || {
points: 10, points: 10,
duration: 120, duration: 120,
}) }
)
function processLoginRequest(email, callback) { function processLoginRequest(email, callback) {
rateLimiter rateLimiterLoginEmail
.consume(email, 1, { method: 'email' }) .consume(email.trim().toLowerCase(), 1, { method: 'email' })
.then(() => { .then(() => {
callback(null, true) callback(null, true)
}) })
@ -22,7 +26,7 @@ function processLoginRequest(email, callback) {
} }
function recordSuccessfulLogin(email, callback) { function recordSuccessfulLogin(email, callback) {
rateLimiter rateLimiterLoginEmail
.delete(email) .delete(email)
.then(() => { .then(() => {
callback() callback()

View file

@ -56,7 +56,7 @@ function rateLimit(rateLimiter, opts = {}) {
} }
} }
function loginRateLimit(req, res, next) { function loginRateLimitEmail(req, res, next) {
const { email } = req.body const { email } = req.body
if (!email) { if (!email) {
return next() return next()
@ -83,7 +83,7 @@ function loginRateLimit(req, res, next) {
const RateLimiterMiddleware = { const RateLimiterMiddleware = {
rateLimit, rateLimit,
loginRateLimit, loginRateLimitEmail,
} }
module.exports = RateLimiterMiddleware module.exports = RateLimiterMiddleware

View file

@ -120,11 +120,14 @@ const openProjectRateLimiter = new RateLimiter('open-project', {
}) })
// Keep in sync with the can-skip-captcha options. // Keep in sync with the can-skip-captcha options.
const overleafLoginRateLimiter = new RateLimiter('overleaf-login', { const overleafLoginRateLimiter = new RateLimiter(
'overleaf-login',
Settings.rateLimit?.login?.ip || {
points: 20, points: 20,
subnetPoints: 200, subnetPoints: 200,
duration: 60, duration: 60,
}) }
)
module.exports = { module.exports = {
RateLimiter, RateLimiter,

View file

@ -41,6 +41,7 @@ const Modules = require('./infrastructure/Modules')
const { const {
RateLimiter, RateLimiter,
openProjectRateLimiter, openProjectRateLimiter,
overleafLoginRateLimiter,
} = require('./infrastructure/RateLimiter') } = require('./infrastructure/RateLimiter')
const RateLimiterMiddleware = require('./Features/Security/RateLimiterMiddleware') const RateLimiterMiddleware = require('./Features/Security/RateLimiterMiddleware')
const InactiveProjectController = require('./Features/InactiveData/InactiveProjectController') const InactiveProjectController = require('./Features/InactiveData/InactiveProjectController')
@ -221,6 +222,8 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
webRouter.post( webRouter.post(
'/login', '/login',
RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s)
RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s)
CaptchaMiddleware.validateCaptcha('login'), CaptchaMiddleware.validateCaptcha('login'),
AuthenticationController.passportLogin AuthenticationController.passportLogin
) )
@ -238,6 +241,8 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
webRouter.get('/login/legacy', UserPagesController.loginPage) webRouter.get('/login/legacy', UserPagesController.loginPage)
webRouter.post( webRouter.post(
'/login/legacy', '/login/legacy',
RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s)
RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s)
CaptchaMiddleware.validateCaptcha('login'), CaptchaMiddleware.validateCaptcha('login'),
AuthenticationController.passportLogin AuthenticationController.passportLogin
) )

View file

@ -727,6 +727,10 @@ module.exports = {
everyone: process.env.RATE_LIMIT_AUTO_COMPILE_EVERYONE || 100, everyone: process.env.RATE_LIMIT_AUTO_COMPILE_EVERYONE || 100,
standard: process.env.RATE_LIMIT_AUTO_COMPILE_STANDARD || 25, standard: process.env.RATE_LIMIT_AUTO_COMPILE_STANDARD || 25,
}, },
login: {
ip: { points: 20, subnetPoints: 200, duration: 60 },
email: { points: 10, duration: 120 },
},
}, },
analytics: { analytics: {

View file

@ -107,4 +107,45 @@ describe('Authentication', function () {
expect(auditLogEntry.ipAddress).to.equal('127.0.0.1') expect(auditLogEntry.ipAddress).to.equal('127.0.0.1')
}) })
}) })
describe('rate-limit', function () {
beforeEach('fetchCsrfToken', async function () {
await user.login()
await user.logout()
await user.getCsrfToken()
})
const tryLogin = async (i = 0) => {
const {
response: { statusCode },
} = await user.doRequest('POST', {
url: Settings.enableLegacyLogin ? '/login/legacy' : '/login',
json: {
email: `${user.email}${' '.repeat(i)}`,
password: 'wrong-password',
'g-recaptcha-response': 'valid',
},
})
return statusCode
}
it('should return 429 after 10 unsuccessful login attempts', async function () {
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin()
expect(statusCode).to.equal(401)
}
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin()
expect(statusCode).to.equal(429)
}
})
it('ignore extra spaces in email address', async function () {
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin(i)
expect(statusCode).to.equal(401)
}
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin(i)
expect(statusCode).to.equal(429)
}
})
})
}) })

View file

@ -388,24 +388,6 @@ describe('AuthenticationController', function () {
}) })
}) })
describe('when the users rate limit', function () {
beforeEach(function () {
this.LoginRateLimiter.processLoginRequest.yields(null, false)
})
it('should block the request if the limit has been exceeded', function (done) {
this.AuthenticationController.doPassportLogin(
this.req,
this.req.body.email,
this.req.body.password,
this.cb
)
this.cb.callCount.should.equal(1)
this.cb.calledWith(null, null).should.equal(true)
done()
})
})
describe('when the user is authenticated', function () { describe('when the user is authenticated', function () {
beforeEach(function () { beforeEach(function () {
this.cb = sinon.stub() this.cb = sinon.stub()