Skip to content

Commit e1099da

Browse files
committed
Add ambiguous error messages option to Accounts
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.
1 parent d03b352 commit e1099da

File tree

2 files changed

+113
-33
lines changed

2 files changed

+113
-33
lines changed

packages/accounts-base/accounts_common.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ export class AccountsCommon {
8686
// - passwordResetTokenExpirationInDays {Number}
8787
// Number of days since password reset token creation until the
8888
// token cannt be used any longer (password reset token expires).
89+
// - ambiguousErrorMessages {Boolean}
90+
// Return ambiguous error messages from login failures to prevent
91+
// user enumeration.
8992

9093
/**
9194
* @summary Set global accounts options.
@@ -98,6 +101,7 @@ export class AccountsCommon {
98101
* @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.
99102
* @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.
100103
* @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.
104+
* @param {Boolean} options.ambiguousErrorMessages Return ambiguous error messages from login failures to prevent user enumeration. Defaults to false.
101105
*/
102106
config(options) {
103107
var self = this;
@@ -130,7 +134,8 @@ export class AccountsCommon {
130134

131135
// validate option keys
132136
var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", "passwordEnrollTokenExpirationInDays",
133-
"restrictCreationByEmailDomain", "loginExpirationInDays", "passwordResetTokenExpirationInDays"];
137+
"restrictCreationByEmailDomain", "loginExpirationInDays", "passwordResetTokenExpirationInDays",
138+
"ambiguousErrorMessages"];
134139
_.each(_.keys(options), function (key) {
135140
if (!_.contains(VALID_KEYS, key)) {
136141
throw new Error("Accounts.config: Invalid key: " + key);

packages/accounts-password/password_server.js

Lines changed: 107 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ Accounts._checkPassword = function (user, password) {
6666
password = getPasswordString(password);
6767

6868
if (! bcryptCompare(password, user.services.password.bcrypt)) {
69-
result.error = new Meteor.Error(403, "Incorrect password");
69+
if(Accounts._options.ambiguousErrorMessages) {
70+
result.error = new Meteor.Error(403, "Login failure. Please check your username and password.");
71+
} else {
72+
result.error = new Meteor.Error(403, "Incorrect password");
73+
}
7074
}
7175

7276
return result;
@@ -202,7 +206,11 @@ var checkForCaseInsensitiveDuplicates = function (fieldName, displayName, fieldV
202206
// Otherwise, check to see if there are multiple matches or a match
203207
// that is not us
204208
(matchedUsers.length > 1 || matchedUsers[0]._id !== ownUserId))) {
205-
throw new Meteor.Error(403, displayName + " already exists.");
209+
if(Accounts._options.ambiguousErrorMessages) {
210+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
211+
} else {
212+
throw new Meteor.Error(403, displayName + " already exists.");
213+
}
206214
}
207215
}
208216
};
@@ -254,12 +262,22 @@ Accounts.registerLoginHandler("password", function (options) {
254262

255263

256264
var user = Accounts._findUserByQuery(options.user);
257-
if (!user)
258-
throw new Meteor.Error(403, "User not found");
259-
265+
if (!user) {
266+
if(Accounts._options.ambiguousErrorMessages) {
267+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
268+
} else {
269+
throw new Meteor.Error(403, "User not found");
270+
}
271+
}
272+
260273
if (!user.services || !user.services.password ||
261-
!(user.services.password.bcrypt || user.services.password.srp))
262-
throw new Meteor.Error(403, "User has no password set");
274+
!(user.services.password.bcrypt || user.services.password.srp)) {
275+
if(Accounts._options.ambiguousErrorMessages) {
276+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
277+
} else {
278+
throw new Meteor.Error(403, "User has no password set");
279+
}
280+
}
263281

264282
if (!user.services.password.bcrypt) {
265283
if (typeof options.password === "string") {
@@ -272,10 +290,16 @@ Accounts.registerLoginHandler("password", function (options) {
272290
identity: verifier.identity, salt: verifier.salt});
273291

274292
if (verifier.verifier !== newVerifier.verifier) {
275-
return {
276-
userId: user._id,
277-
error: new Meteor.Error(403, "Incorrect password")
278-
};
293+
if(Accounts._options.ambiguousErrorMessages) {
294+
return {
295+
error: new Meteor.Error(403, "Login failure. Please check your username and password.")
296+
}
297+
} else {
298+
return {
299+
userId: user._id,
300+
error: new Meteor.Error(403, "Incorrect password")
301+
};
302+
}
279303
}
280304

281305
return {userId: user._id};
@@ -320,16 +344,27 @@ Accounts.registerLoginHandler("password", function (options) {
320344
});
321345

322346
var user = Accounts._findUserByQuery(options.user);
323-
if (!user)
324-
throw new Meteor.Error(403, "User not found");
347+
if (!user) {
348+
if(Accounts._options.ambiguousErrorMessages) {
349+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
350+
} else {
351+
throw new Meteor.Error(403, "User not found");
352+
}
353+
}
325354

326355
// Check to see if another simultaneous login has already upgraded
327356
// the user record to bcrypt.
328357
if (user.services && user.services.password && user.services.password.bcrypt)
329358
return checkPassword(user, options.password);
330359

331-
if (!(user.services && user.services.password && user.services.password.srp))
332-
throw new Meteor.Error(403, "User has no password set");
360+
if (!(user.services && user.services.password && user.services.password.srp)) {
361+
if(Accounts._options.ambiguousErrorMessages) {
362+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
363+
} else {
364+
throw new Meteor.Error(403, "User has no password set");
365+
}
366+
}
367+
333368

334369
var v1 = user.services.password.srp.verifier;
335370
var v2 = SRP.generateVerifier(
@@ -339,11 +374,18 @@ Accounts.registerLoginHandler("password", function (options) {
339374
salt: user.services.password.srp.salt
340375
}
341376
).verifier;
342-
if (v1 !== v2)
343-
return {
344-
userId: user._id,
345-
error: new Meteor.Error(403, "Incorrect password")
346-
};
377+
if (v1 !== v2) {
378+
if(Accounts._options.ambiguousErrorMessages) {
379+
return {
380+
error: new Meteor.Error(403, "Login failure. Please check your username and password.")
381+
};
382+
} else {
383+
return {
384+
userId: user._id,
385+
error: new Meteor.Error(403, "Incorrect password")
386+
};
387+
}
388+
}
347389

348390
// Upgrade to bcrypt on successful login.
349391
var salted = hashPassword(options.password);
@@ -377,8 +419,13 @@ Accounts.setUsername = function (userId, newUsername) {
377419
check(newUsername, NonEmptyString);
378420

379421
var user = Meteor.users.findOne(userId);
380-
if (!user)
381-
throw new Meteor.Error(403, "User not found");
422+
if (!user) {
423+
if(Accounts._options.ambiguousErrorMessages) {
424+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
425+
} else {
426+
throw new Meteor.Error(403, "User not found");
427+
}
428+
}
382429

383430
var oldUsername = user.username;
384431

@@ -421,12 +468,22 @@ Meteor.methods({changePassword: function (oldPassword, newPassword) {
421468
throw new Meteor.Error(401, "Must be logged in");
422469

423470
var user = Meteor.users.findOne(this.userId);
424-
if (!user)
425-
throw new Meteor.Error(403, "User not found");
471+
if (!user) {
472+
if(Accounts._options.ambiguousErrorMessages) {
473+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
474+
} else {
475+
throw new Meteor.Error(403, "User not found");
476+
}
477+
}
426478

427479
if (!user.services || !user.services.password ||
428-
(!user.services.password.bcrypt && !user.services.password.srp))
429-
throw new Meteor.Error(403, "User has no password set");
480+
(!user.services.password.bcrypt && !user.services.password.srp)) {
481+
if(Accounts._options.ambiguousErrorMessages) {
482+
throw new Meteor.Error(403, "Login failure. Please check your username and password.");
483+
} else {
484+
throw new Meteor.Error(403, "User has no password set");
485+
}
486+
}
430487

431488
if (! user.services.password.bcrypt) {
432489
throw new Meteor.Error(400, "old password format", EJSON.stringify({
@@ -505,8 +562,14 @@ Meteor.methods({forgotPassword: function (options) {
505562
check(options, {email: String});
506563

507564
var user = Accounts.findUserByEmail(options.email);
508-
if (!user)
509-
throw new Meteor.Error(403, "User not found");
565+
if (!user) {
566+
if(Accounts._options.ambiguousErrorMessages) {
567+
// 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
568+
return
569+
} else {
570+
throw new Meteor.Error(403, "User not found");
571+
}
572+
}
510573

511574
const emails = _.pluck(user.emails || [], 'address');
512575
const caseSensitiveEmail = _.find(emails, email => {
@@ -529,14 +592,26 @@ Meteor.methods({forgotPassword: function (options) {
529592
Accounts.sendResetPasswordEmail = function (userId, email) {
530593
// Make sure the user exists, and email is one of their addresses.
531594
var user = Meteor.users.findOne(userId);
532-
if (!user)
533-
throw new Error("Can't find user");
595+
if (!user) {
596+
if(Accounts._options.ambiguousErrorMessages) {
597+
// 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
598+
return
599+
} else {
600+
throw new Error("Can't find user");
601+
}
602+
}
534603
// pick the first email if we weren't passed an email.
535604
if (!email && user.emails && user.emails[0])
536605
email = user.emails[0].address;
537606
// make sure we have a valid email
538-
if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email))
539-
throw new Error("No such email for user.");
607+
if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email)) {
608+
if(Accounts._options.ambiguousErrorMessages) {
609+
// 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
610+
return
611+
} else {
612+
throw new Error("No such email for user.");
613+
}
614+
}
540615

541616
var token = Random.secret();
542617
var when = new Date();

0 commit comments

Comments
 (0)