mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #11508 from overleaf/jk-password-disallow-substring
[web] Metric for passwords too similar to email GitOrigin-RevId: cf8320fc3c9561b4dc6d54a3e97db96400ece2a9
This commit is contained in:
parent
f996c95165
commit
c4ecded316
4 changed files with 223 additions and 0 deletions
|
@ -14,10 +14,21 @@ const util = require('util')
|
||||||
const HaveIBeenPwned = require('./HaveIBeenPwned')
|
const HaveIBeenPwned = require('./HaveIBeenPwned')
|
||||||
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
|
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
|
||||||
const logger = require('@overleaf/logger')
|
const logger = require('@overleaf/logger')
|
||||||
|
const DiffHelper = require('../Helpers/DiffHelper')
|
||||||
const Metrics = require('@overleaf/metrics')
|
const Metrics = require('@overleaf/metrics')
|
||||||
|
|
||||||
const BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12
|
const BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12
|
||||||
const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a'
|
const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a'
|
||||||
|
const MAX_SIMILARITY = 0.7
|
||||||
|
|
||||||
|
function _exceedsMaximumLengthRatio(password, maxSimilarity, value) {
|
||||||
|
const passwordLength = password.length
|
||||||
|
const lengthBoundSimilarity = (maxSimilarity / 2) * passwordLength
|
||||||
|
const valueLength = value.length
|
||||||
|
return (
|
||||||
|
passwordLength >= 10 * valueLength && valueLength < lengthBoundSimilarity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const _checkWriteResult = function (result, callback) {
|
const _checkWriteResult = function (result, callback) {
|
||||||
// for MongoDB
|
// for MongoDB
|
||||||
|
@ -203,6 +214,7 @@ const AuthenticationManager = {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (typeof email === 'string' && email !== '') {
|
if (typeof email === 'string' && email !== '') {
|
||||||
|
// TODO: remove this check once the password-too-similar check below is active
|
||||||
const startOfEmail = email.split('@')[0]
|
const startOfEmail = email.split('@')[0]
|
||||||
if (
|
if (
|
||||||
password.indexOf(email) !== -1 ||
|
password.indexOf(email) !== -1 ||
|
||||||
|
@ -213,6 +225,18 @@ const AuthenticationManager = {
|
||||||
info: { code: 'contains_email' },
|
info: { code: 'contains_email' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const passwordTooSimilarError =
|
||||||
|
AuthenticationManager._validatePasswordNotTooSimilar(password, email)
|
||||||
|
if (passwordTooSimilarError) {
|
||||||
|
Metrics.inc('password-too-similar-to-email')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error },
|
||||||
|
'error while checking password similarity to email'
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
@ -342,6 +366,33 @@ const AuthenticationManager = {
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the password is similar to (parts of) the email address.
|
||||||
|
* For now, this merely sends a metric when the password and
|
||||||
|
* email address are deemed to be too similar to each other.
|
||||||
|
* Later we will reject passwords that fail this check.
|
||||||
|
*
|
||||||
|
* This logic was borrowed from the django project:
|
||||||
|
* https://github.com/django/django/blob/fa3afc5d86f1f040922cca2029d6a34301597a70/django/contrib/auth/password_validation.py#L159-L214
|
||||||
|
*/
|
||||||
|
_validatePasswordNotTooSimilar(password, email) {
|
||||||
|
password = password.toLowerCase()
|
||||||
|
email = email.toLowerCase()
|
||||||
|
const stringsToCheck = [email].concat(email.split(/\W+/))
|
||||||
|
for (const emailPart of stringsToCheck) {
|
||||||
|
if (!_exceedsMaximumLengthRatio(password, MAX_SIMILARITY, emailPart)) {
|
||||||
|
const similarity = DiffHelper.stringSimilarity(password, emailPart)
|
||||||
|
if (similarity > MAX_SIMILARITY) {
|
||||||
|
logger.warn(
|
||||||
|
{ email, emailPart, similarity, maxSimilarity: MAX_SIMILARITY },
|
||||||
|
'Password too similar to email'
|
||||||
|
)
|
||||||
|
return new Error('password is too similar to email')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthenticationManager.promises = {
|
AuthenticationManager.promises = {
|
||||||
|
|
55
services/web/app/src/Features/Helpers/DiffHelper.js
Normal file
55
services/web/app/src/Features/Helpers/DiffHelper.js
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
const MAX_LENGTH = 254
|
||||||
|
|
||||||
|
function _calculateRatio(matches, length) {
|
||||||
|
if (length) {
|
||||||
|
const ratio = (2.0 * matches) / length
|
||||||
|
const rounded = Math.floor(ratio * 100) / 100
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ported from python's `difflib`:
|
||||||
|
* https://github.com/python/cpython/blob/0415cf895f96ae3f896f1f25f0c030a820845e13/Lib/difflib.py#L622-L649
|
||||||
|
*
|
||||||
|
* Accepts two strings, `a` and `b`, and returns a float ratio
|
||||||
|
* corresponding (approximatey) to the overlap between the strings.
|
||||||
|
* Identical strings produce 1.0, completely different strings produce 0.0
|
||||||
|
* */
|
||||||
|
function stringSimilarity(a, b) {
|
||||||
|
if (
|
||||||
|
typeof a !== 'string' ||
|
||||||
|
typeof b !== 'string' ||
|
||||||
|
a.length > MAX_LENGTH ||
|
||||||
|
b.length > MAX_LENGTH
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid input to quickMatchRatio')
|
||||||
|
}
|
||||||
|
// Count how many times each character occurs in `b`
|
||||||
|
const fullBCount = {}
|
||||||
|
b.split('').forEach(e => {
|
||||||
|
fullBCount[e] = (fullBCount[e] || 0) + 1
|
||||||
|
})
|
||||||
|
// avail[x] is the number of times x appears in 'b' less the
|
||||||
|
// number of times we've seen it in 'a' so far ... kinda
|
||||||
|
const avail = {}
|
||||||
|
let matches = 0
|
||||||
|
a.split('').forEach(e => {
|
||||||
|
let n = null
|
||||||
|
if (Object.hasOwn(avail, e)) {
|
||||||
|
n = avail[e]
|
||||||
|
} else {
|
||||||
|
n = fullBCount[e] || 0
|
||||||
|
}
|
||||||
|
avail[e] = n - 1
|
||||||
|
if (n > 0) {
|
||||||
|
matches = matches + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return _calculateRatio(matches, a.length + b.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
stringSimilarity,
|
||||||
|
}
|
|
@ -672,6 +672,52 @@ describe('AuthenticationManager', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('_validatePasswordNotTooSimilar', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.metrics.inc.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return an error when the password is too similar to email', function () {
|
||||||
|
const password = 'someuser1234'
|
||||||
|
const email = 'someuser@example.com'
|
||||||
|
const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
|
||||||
|
password,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
expect(error).to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return an error when the password is re-arranged elements of the email', function () {
|
||||||
|
const password = 'su2oe1em3re'
|
||||||
|
const email = 'someuser@example.com'
|
||||||
|
const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
|
||||||
|
password,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
expect(error).to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return nothing when the password different from email', function () {
|
||||||
|
const password = '58WyLvr'
|
||||||
|
const email = 'someuser@example.com'
|
||||||
|
const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
|
||||||
|
password,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
expect(error).to.not.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return nothing when the password is much longer than parts of the email', function () {
|
||||||
|
const password = new Array(30).fill('a').join('')
|
||||||
|
const email = 'a@cd.com'
|
||||||
|
const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
|
||||||
|
password,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
expect(error).to.not.exist
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('setUserPassword', function () {
|
describe('setUserPassword', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
this.user_id = ObjectId()
|
this.user_id = ObjectId()
|
||||||
|
@ -813,8 +859,45 @@ describe('AuthenticationManager', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('password too similar to email', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.user.email = 'foobarbazquux@example.com'
|
||||||
|
this.password = 'foobarbaz'
|
||||||
|
this.metrics.inc.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should send a metric when the password is too similar to the email', function (done) {
|
||||||
|
this.AuthenticationManager.setUserPassword(
|
||||||
|
this.user,
|
||||||
|
this.password,
|
||||||
|
err => {
|
||||||
|
expect(err).to.not.exist
|
||||||
|
expect(
|
||||||
|
this.metrics.inc.calledWith('password-too-similar-to-email')
|
||||||
|
).to.equal(true)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should send a metric when the password is too similar to the email, regardless of case', function (done) {
|
||||||
|
this.AuthenticationManager.setUserPassword(
|
||||||
|
this.user,
|
||||||
|
this.password.toUpperCase(),
|
||||||
|
err => {
|
||||||
|
expect(err).to.not.exist
|
||||||
|
expect(
|
||||||
|
this.metrics.inc.calledWith('password-too-similar-to-email')
|
||||||
|
).to.equal(true)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('successful password set attempt', function () {
|
describe('successful password set attempt', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
this.metrics.inc.reset()
|
||||||
this.UserGetter.getUser = sinon.stub().yields(null, { overleaf: null })
|
this.UserGetter.getUser = sinon.stub().yields(null, { overleaf: null })
|
||||||
this.AuthenticationManager.setUserPassword(
|
this.AuthenticationManager.setUserPassword(
|
||||||
this.user,
|
this.user,
|
||||||
|
@ -843,6 +926,12 @@ describe('AuthenticationManager', function () {
|
||||||
this.bcrypt.hash.calledWith(this.password, this.salt).should.equal(true)
|
this.bcrypt.hash.calledWith(this.password, this.salt).should.equal(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not send a metric for password-too-similar-to-email', function () {
|
||||||
|
expect(
|
||||||
|
this.metrics.inc.calledWith('password-too-similar-to-email')
|
||||||
|
).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('should call the callback', function () {
|
it('should call the callback', function () {
|
||||||
this.callback.called.should.equal(true)
|
this.callback.called.should.equal(true)
|
||||||
})
|
})
|
||||||
|
|
28
services/web/test/unit/src/HelperFiles/DiffHelperTests.js
Normal file
28
services/web/test/unit/src/HelperFiles/DiffHelperTests.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const {
|
||||||
|
stringSimilarity,
|
||||||
|
} = require('../../../../app/src/Features/Helpers/DiffHelper')
|
||||||
|
|
||||||
|
describe('DiffHelper', function () {
|
||||||
|
describe('stringSimilarity', function () {
|
||||||
|
it('should have a ratio of 1 for identical strings', function () {
|
||||||
|
expect(stringSimilarity('abcdef', 'abcdef')).to.equal(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have a ratio of 0 for completely different strings', function () {
|
||||||
|
expect(stringSimilarity('abcdef', 'qmglzxv')).to.equal(0.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have a ratio of between 0 and 1 for strings that are similar', function () {
|
||||||
|
const ratio = stringSimilarity('abcdef', 'abcdef@zxvkp')
|
||||||
|
expect(ratio).to.equal(0.66)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject non-string inputs', function () {
|
||||||
|
expect(() => stringSimilarity(1, 'abc')).to.throw
|
||||||
|
expect(() => stringSimilarity('abc', 2)).to.throw
|
||||||
|
expect(() => stringSimilarity('abc', new Array(1000).fill('a').join('')))
|
||||||
|
.to.throw
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue