mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 02:53:48 -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) {
|
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 = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in a new issue