mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
[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:
parent
7212c16dde
commit
5f2718cf29
8 changed files with 137 additions and 112 deletions
|
@ -212,19 +212,6 @@ const AuthenticationController = {
|
|||
if (info != null) {
|
||||
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 auditLog = {
|
||||
ipAddress: req.ip,
|
||||
|
@ -296,7 +283,6 @@ const AuthenticationController = {
|
|||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
const { RateLimiter } = require('../../infrastructure/RateLimiter')
|
||||
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,
|
||||
duration: 120,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
function processLoginRequest(email, callback) {
|
||||
rateLimiter
|
||||
.consume(email, 1, { method: 'email' })
|
||||
rateLimiterLoginEmail
|
||||
.consume(email.trim().toLowerCase(), 1, { method: 'email' })
|
||||
.then(() => {
|
||||
callback(null, true)
|
||||
})
|
||||
|
@ -22,7 +26,7 @@ function processLoginRequest(email, callback) {
|
|||
}
|
||||
|
||||
function recordSuccessfulLogin(email, callback) {
|
||||
rateLimiter
|
||||
rateLimiterLoginEmail
|
||||
.delete(email)
|
||||
.then(() => {
|
||||
callback()
|
||||
|
|
|
@ -56,7 +56,7 @@ function rateLimit(rateLimiter, opts = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
function loginRateLimit(req, res, next) {
|
||||
function loginRateLimitEmail(req, res, next) {
|
||||
const { email } = req.body
|
||||
if (!email) {
|
||||
return next()
|
||||
|
@ -83,7 +83,7 @@ function loginRateLimit(req, res, next) {
|
|||
|
||||
const RateLimiterMiddleware = {
|
||||
rateLimit,
|
||||
loginRateLimit,
|
||||
loginRateLimitEmail,
|
||||
}
|
||||
|
||||
module.exports = RateLimiterMiddleware
|
||||
|
|
|
@ -120,11 +120,14 @@ const openProjectRateLimiter = new RateLimiter('open-project', {
|
|||
})
|
||||
|
||||
// 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,
|
||||
subnetPoints: 200,
|
||||
duration: 60,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = {
|
||||
RateLimiter,
|
||||
|
|
|
@ -41,6 +41,7 @@ const Modules = require('./infrastructure/Modules')
|
|||
const {
|
||||
RateLimiter,
|
||||
openProjectRateLimiter,
|
||||
overleafLoginRateLimiter,
|
||||
} = require('./infrastructure/RateLimiter')
|
||||
const RateLimiterMiddleware = require('./Features/Security/RateLimiterMiddleware')
|
||||
const InactiveProjectController = require('./Features/InactiveData/InactiveProjectController')
|
||||
|
@ -221,6 +222,8 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
|
||||
webRouter.post(
|
||||
'/login',
|
||||
RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s)
|
||||
RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s)
|
||||
CaptchaMiddleware.validateCaptcha('login'),
|
||||
AuthenticationController.passportLogin
|
||||
)
|
||||
|
@ -238,6 +241,8 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
webRouter.get('/login/legacy', UserPagesController.loginPage)
|
||||
webRouter.post(
|
||||
'/login/legacy',
|
||||
RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s)
|
||||
RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s)
|
||||
CaptchaMiddleware.validateCaptcha('login'),
|
||||
AuthenticationController.passportLogin
|
||||
)
|
||||
|
|
|
@ -727,6 +727,10 @@ module.exports = {
|
|||
everyone: process.env.RATE_LIMIT_AUTO_COMPILE_EVERYONE || 100,
|
||||
standard: process.env.RATE_LIMIT_AUTO_COMPILE_STANDARD || 25,
|
||||
},
|
||||
login: {
|
||||
ip: { points: 20, subnetPoints: 200, duration: 60 },
|
||||
email: { points: 10, duration: 120 },
|
||||
},
|
||||
},
|
||||
|
||||
analytics: {
|
||||
|
|
|
@ -107,4 +107,45 @@ describe('Authentication', function () {
|
|||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 () {
|
||||
beforeEach(function () {
|
||||
this.cb = sinon.stub()
|
||||
|
|
Loading…
Reference in a new issue