From e1099dad19ab9169123797477e8e7c02c6532ec6 Mon Sep 17 00:00:00 2001 From: Dan Rubins Date: Sat, 25 Mar 2017 14:39:29 -0700 Subject: [PATCH] Add ambiguous error messages option to Accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add boolean option 'ambiguousErrorMessages' to Accounts config that sends ambiguous error messages to the client in order to mitigate user enumeration. User enumeration still possible via inference upon registration failure, but at least we’re not being as explicit about the failures. --- packages/accounts-base/accounts_common.js | 7 +- packages/accounts-password/password_server.js | 139 ++++++++++++++---- 2 files changed, 113 insertions(+), 33 deletions(-) diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index e968ec2e5e..8c936b8da4 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -86,6 +86,9 @@ export class AccountsCommon { // - passwordResetTokenExpirationInDays {Number} // Number of days since password reset token creation until the // token cannt be used any longer (password reset token expires). + // - ambiguousErrorMessages {Boolean} + // Return ambiguous error messages from login failures to prevent + // user enumeration. /** * @summary Set global accounts options. @@ -98,6 +101,7 @@ export class AccountsCommon { * @param {String} options.oauthSecretKey When using the `oauth-encryption` package, the 16 byte key using to encrypt sensitive account credentials in the database, encoded in base64. This option may only be specifed on the server. See packages/oauth-encryption/README.md for details. * @param {Number} options.passwordResetTokenExpirationInDays The number of days from when a link to reset password is sent until token expires and user can't reset password with the link anymore. Defaults to 3. * @param {Number} options.passwordEnrollTokenExpirationInDays The number of days from when a link to set inital password is sent until token expires and user can't set password with the link anymore. Defaults to 30. + * @param {Boolean} options.ambiguousErrorMessages Return ambiguous error messages from login failures to prevent user enumeration. Defaults to false. */ config(options) { var self = this; @@ -130,7 +134,8 @@ export class AccountsCommon { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", "passwordEnrollTokenExpirationInDays", - "restrictCreationByEmailDomain", "loginExpirationInDays", "passwordResetTokenExpirationInDays"]; + "restrictCreationByEmailDomain", "loginExpirationInDays", "passwordResetTokenExpirationInDays", + "ambiguousErrorMessages"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 5bc154917b..0562ef1106 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -66,7 +66,11 @@ Accounts._checkPassword = function (user, password) { password = getPasswordString(password); if (! bcryptCompare(password, user.services.password.bcrypt)) { - result.error = new Meteor.Error(403, "Incorrect password"); + if(Accounts._options.ambiguousErrorMessages) { + result.error = new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + result.error = new Meteor.Error(403, "Incorrect password"); + } } return result; @@ -202,7 +206,11 @@ var checkForCaseInsensitiveDuplicates = function (fieldName, displayName, fieldV // Otherwise, check to see if there are multiple matches or a match // that is not us (matchedUsers.length > 1 || matchedUsers[0]._id !== ownUserId))) { - throw new Meteor.Error(403, displayName + " already exists."); + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, displayName + " already exists."); + } } } }; @@ -254,12 +262,22 @@ Accounts.registerLoginHandler("password", function (options) { var user = Accounts._findUserByQuery(options.user); - if (!user) - throw new Meteor.Error(403, "User not found"); - + if (!user) { + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, "User not found"); + } + } + if (!user.services || !user.services.password || - !(user.services.password.bcrypt || user.services.password.srp)) - throw new Meteor.Error(403, "User has no password set"); + !(user.services.password.bcrypt || user.services.password.srp)) { + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, "User has no password set"); + } + } if (!user.services.password.bcrypt) { if (typeof options.password === "string") { @@ -272,10 +290,16 @@ Accounts.registerLoginHandler("password", function (options) { identity: verifier.identity, salt: verifier.salt}); if (verifier.verifier !== newVerifier.verifier) { - return { - userId: user._id, - error: new Meteor.Error(403, "Incorrect password") - }; + if(Accounts._options.ambiguousErrorMessages) { + return { + error: new Meteor.Error(403, "Login failure. Please check your username and password.") + } + } else { + return { + userId: user._id, + error: new Meteor.Error(403, "Incorrect password") + }; + } } return {userId: user._id}; @@ -320,16 +344,27 @@ Accounts.registerLoginHandler("password", function (options) { }); var user = Accounts._findUserByQuery(options.user); - if (!user) - throw new Meteor.Error(403, "User not found"); + if (!user) { + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, "User not found"); + } + } // Check to see if another simultaneous login has already upgraded // the user record to bcrypt. if (user.services && user.services.password && user.services.password.bcrypt) return checkPassword(user, options.password); - if (!(user.services && user.services.password && user.services.password.srp)) - throw new Meteor.Error(403, "User has no password set"); + if (!(user.services && user.services.password && user.services.password.srp)) { + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, "User has no password set"); + } + } + var v1 = user.services.password.srp.verifier; var v2 = SRP.generateVerifier( @@ -339,11 +374,18 @@ Accounts.registerLoginHandler("password", function (options) { salt: user.services.password.srp.salt } ).verifier; - if (v1 !== v2) - return { - userId: user._id, - error: new Meteor.Error(403, "Incorrect password") - }; + if (v1 !== v2) { + if(Accounts._options.ambiguousErrorMessages) { + return { + error: new Meteor.Error(403, "Login failure. Please check your username and password.") + }; + } else { + return { + userId: user._id, + error: new Meteor.Error(403, "Incorrect password") + }; + } + } // Upgrade to bcrypt on successful login. var salted = hashPassword(options.password); @@ -377,8 +419,13 @@ Accounts.setUsername = function (userId, newUsername) { check(newUsername, NonEmptyString); var user = Meteor.users.findOne(userId); - if (!user) - throw new Meteor.Error(403, "User not found"); + if (!user) { + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, "User not found"); + } + } var oldUsername = user.username; @@ -421,12 +468,22 @@ Meteor.methods({changePassword: function (oldPassword, newPassword) { throw new Meteor.Error(401, "Must be logged in"); var user = Meteor.users.findOne(this.userId); - if (!user) - throw new Meteor.Error(403, "User not found"); + if (!user) { + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, "User not found"); + } + } if (!user.services || !user.services.password || - (!user.services.password.bcrypt && !user.services.password.srp)) - throw new Meteor.Error(403, "User has no password set"); + (!user.services.password.bcrypt && !user.services.password.srp)) { + if(Accounts._options.ambiguousErrorMessages) { + throw new Meteor.Error(403, "Login failure. Please check your username and password."); + } else { + throw new Meteor.Error(403, "User has no password set"); + } + } if (! user.services.password.bcrypt) { throw new Meteor.Error(400, "old password format", EJSON.stringify({ @@ -505,8 +562,14 @@ Meteor.methods({forgotPassword: function (options) { check(options, {email: String}); var user = Accounts.findUserByEmail(options.email); - if (!user) - throw new Meteor.Error(403, "User not found"); + if (!user) { + if(Accounts._options.ambiguousErrorMessages) { + // If ambiguousErrorMessages is set, we don't want to give away that the email does not exist, so just return as if everything is a-OK + return + } else { + throw new Meteor.Error(403, "User not found"); + } + } const emails = _.pluck(user.emails || [], 'address'); const caseSensitiveEmail = _.find(emails, email => { @@ -529,14 +592,26 @@ Meteor.methods({forgotPassword: function (options) { Accounts.sendResetPasswordEmail = function (userId, email) { // Make sure the user exists, and email is one of their addresses. var user = Meteor.users.findOne(userId); - if (!user) - throw new Error("Can't find user"); + if (!user) { + if(Accounts._options.ambiguousErrorMessages) { + // If ambiguousErrorMessages is set, we don't want to give away that the user does not exist, so just return as if everything is a-OK + return + } else { + throw new Error("Can't find user"); + } + } // pick the first email if we weren't passed an email. if (!email && user.emails && user.emails[0]) email = user.emails[0].address; // make sure we have a valid email - if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) - throw new Error("No such email for user."); + if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) { + if(Accounts._options.ambiguousErrorMessages) { + // If ambiguousErrorMessages is set, we don't want to give away that the user does not exist, so just return as if everything is a-OK + return + } else { + throw new Error("No such email for user."); + } + } var token = Random.secret(); var when = new Date();