-
Notifications
You must be signed in to change notification settings - Fork 304
A simple service for handling blacklisting of usernames #893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
04babec
8922bdd
46bdd70
5980af7
db0d55b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "useTheBigUsernameBlacklist": true, | ||
| "customBlacklistedUsernames": [] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| const AuthRequest = require('./auth-request') | ||
| const WebIdTlsCertificate = require('../models/webid-tls-certificate') | ||
| const debug = require('../debug').accounts | ||
| const blacklistService = require('../services/blacklist-service') | ||
|
|
||
| /** | ||
| * Represents a 'create new user account' http request (either a POST to the | ||
|
|
@@ -115,30 +116,28 @@ class CreateAccountRequest extends AuthRequest { | |
| /** | ||
| * Creates an account for a given user (from a POST to `/api/accounts/new`) | ||
| * | ||
| * @throws {Error} An http 400 error if an account already exists | ||
| * @throws {Error} If errors were encountering while validating the username. | ||
| * | ||
| * @return {Promise<UserAccount>} Resolves with newly created account instance | ||
| */ | ||
| createAccount () { | ||
| async createAccount () { | ||
| let userAccount = this.userAccount | ||
| let accountManager = this.accountManager | ||
|
|
||
| return Promise.resolve(userAccount) | ||
| .then(this.cancelIfUsernameInvalid.bind(this)) | ||
| .then(this.cancelIfAccountExists.bind(this)) | ||
| .then(this.createAccountStorage.bind(this)) | ||
| .then(this.saveCredentialsFor.bind(this)) | ||
| .then(this.sendResponse.bind(this)) | ||
| .then(userAccount => { | ||
| // 'return' not used deliberately, no need to block and wait for email | ||
| if (userAccount && userAccount.email) { | ||
| debug('Sending Welcome email') | ||
| accountManager.sendWelcomeEmail(userAccount) | ||
| } | ||
| }) | ||
| .then(() => { | ||
| return userAccount | ||
| }) | ||
| this.cancelIfUsernameInvalid(userAccount) | ||
| this.cancelIfBlacklistedUsername(userAccount) | ||
| await this.cancelIfAccountExists(userAccount) | ||
| await this.createAccountStorage(userAccount) | ||
| await this.saveCredentialsFor(userAccount) | ||
| await this.sendResponse(userAccount) | ||
|
|
||
| // 'return' not used deliberately, no need to block and wait for email | ||
| if (userAccount && userAccount.email) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about looking up the email in the Have I Been Pwned? database?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We do this on the front-end side after #859; we could do it on the back-end as well, but I would suggest creating a separate issue for that.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that's orthogonal/unrelated to what this PR is trying to do. |
||
| debug('Sending Welcome email') | ||
| accountManager.sendWelcomeEmail(userAccount) | ||
| } | ||
|
|
||
| return userAccount | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -196,12 +195,33 @@ class CreateAccountRequest extends AuthRequest { | |
| * @throws {Error} If errors were encountering while validating the | ||
| * username. | ||
| * | ||
| * @return {Promise<UserAccount>} Chainable | ||
| * @return {UserAccount} Chainable | ||
| */ | ||
| cancelIfUsernameInvalid (userAccount) { | ||
| if (!userAccount.username || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(userAccount.username)) { | ||
| debug('Invalid username ' + userAccount.username) | ||
| const error = new Error('Invalid username') | ||
| const error = new Error('Invalid username (contains invalid characters)') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From an UX perspective it might make more sense which characters where invalid. One way to reduce invalid passwords is by showing a „strength meter”.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is also handled in #859. Apart from that I agree that we need to make the feedback better. But as we do have some handling for it front-end side I'm leaning toward making a separate issue for this, if at all. |
||
| error.status = 400 | ||
megoth marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| throw error | ||
| } | ||
|
|
||
| return userAccount | ||
| } | ||
|
|
||
| /** | ||
| * Check if a username is a valid slug. | ||
| * | ||
| * @param userAccount {UserAccount} Instance of the account to be created | ||
| * | ||
| * @throws {Error} If username is blacklisted | ||
| * | ||
| * @return {UserAccount} Chainable | ||
| */ | ||
| cancelIfBlacklistedUsername (userAccount) { | ||
| const validUsername = blacklistService.validate(userAccount.username) | ||
| if (!validUsername) { | ||
| debug('Invalid username ' + userAccount.username) | ||
| const error = new Error('Invalid username (username is blacklisted)') | ||
| error.status = 400 | ||
| throw error | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| const blacklistConfig = require('../../config/usernames-blacklist.json') | ||
| const blacklist = require('the-big-username-blacklist').list | ||
|
|
||
| class BlacklistService { | ||
| constructor () { | ||
| this.reset() | ||
| } | ||
|
|
||
| addWord (word) { | ||
| this.list.push(BlacklistService._prepareWord(word)) | ||
| } | ||
|
|
||
| reset (config) { | ||
| this.list = BlacklistService._initList(config) | ||
| } | ||
|
|
||
| validate (word) { | ||
| return this.list.indexOf(BlacklistService._prepareWord(word)) === -1 | ||
megoth marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| static _initList (config = blacklistConfig) { | ||
| return [ | ||
| ...(config.useTheBigUsernameBlacklist ? blacklist : []), | ||
| ...config.customBlacklistedUsernames | ||
| ] | ||
| } | ||
|
|
||
| static _prepareWord (word) { | ||
| return word.trim().toLocaleLowerCase() | ||
| } | ||
| } | ||
|
|
||
| module.exports = new BlacklistService() | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| 'use strict' | ||
|
|
||
| const chai = require('chai') | ||
| const expect = chai.expect | ||
|
|
||
| const blacklist = require('the-big-username-blacklist').list | ||
| const blacklistService = require('../../lib/services/blacklist-service') | ||
|
|
||
| describe('BlacklistService', () => { | ||
| afterEach(() => blacklistService.reset()) | ||
|
|
||
| describe('addWord', () => { | ||
| it('allows adding words', () => { | ||
| const numberOfBlacklistedWords = blacklistService.list.length | ||
| blacklistService.addWord('foo') | ||
| expect(blacklistService.list.length).to.equal(numberOfBlacklistedWords + 1) | ||
| }) | ||
| }) | ||
|
|
||
| describe('reset', () => { | ||
| it('will reset list of blacklisted words', () => { | ||
| blacklistService.addWord('foo') | ||
| blacklistService.reset() | ||
| expect(blacklistService.list.length).to.equal(blacklist.length) | ||
| }) | ||
|
|
||
| it('can configure service via reset', () => { | ||
| blacklistService.reset({ | ||
| useTheBigUsernameBlacklist: false, | ||
| customBlacklistedUsernames: ['foo'] | ||
| }) | ||
| expect(blacklistService.list.length).to.equal(1) | ||
| expect(blacklistService.validate('admin')).to.equal(true) | ||
| }) | ||
|
|
||
| it('is a singleton', () => { | ||
| const instanceA = blacklistService | ||
| blacklistService.reset({ customBlacklistedUsernames: ['foo'] }) | ||
| expect(instanceA.validate('foo')).to.equal(blacklistService.validate('foo')) | ||
| }) | ||
| }) | ||
|
|
||
| describe('validate', () => { | ||
| it('validates given a default list of blacklisted usernames', () => { | ||
| const validWords = blacklist.reduce((memo, word) => memo + (blacklistService.validate(word) ? 1 : 0), 0) | ||
megoth marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| expect(validWords).to.equal(0) | ||
| }) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is that an information leak?
That is, do you have a protection against brute force the usernames in place?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're thinking that people could brute force to find out which usernames are taken? But that is already publicly available as it is now... i.e. WebIDs are not publicly listed in an index, but you could brute force through a list of username given a specific WebID-pattern to find out which usernames have WebIDs on a given server as all PODs have public "frontpage" as of now - that might change in the future.
I'm not familiar with how we handle brute force attempts at logging in with a given username though, if we handle it at all right now... (maybe @kjetilk, @RubenVerborgh, or @dmitrizagidulin know?) Definitely something we need to have in place before version 6.0.0 at least, and we should probably create a separate issue for it (I'll wait for response by the people tagged first).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We do not currently handle brute force attacks to find out what usernames are taken. Like you said, it's publicly available information (you can check to see if an account is taken via an API call).
As far as login brute force attacks,
bcryptpassword library provides a decent protection against those (it may make sense to increase our bcrypt params to take slightly longer).