feat: remove invalid email response and instead return email and OTP required to protect against enumeration attacks.

This commit is contained in:
Ike Kottlowski
2026-01-27 22:09:22 -05:00
parent 2a458807a5
commit 41348b3158
4 changed files with 17 additions and 26 deletions

View File

@@ -69,13 +69,9 @@ public static class SendAccessConstants
/// </summary> /// </summary>
public const string EmailRequired = "email_required"; public const string EmailRequired = "email_required";
/// <summary> /// <summary>
/// Represents the error code indicating that an email address is invalid.
/// </summary>
public const string EmailInvalid = "email_invalid";
/// <summary>
/// Represents the status indicating that both email and OTP are required, and the OTP has been sent. /// Represents the status indicating that both email and OTP are required, and the OTP has been sent.
/// </summary> /// </summary>
public const string EmailOtpSent = "email_and_otp_required_otp_sent"; public const string EmailAndOtpRequired = "email_and_otp_required";
/// <summary> /// <summary>
/// Represents the status indicating that both email and OTP are required, and the OTP is invalid. /// Represents the status indicating that both email and OTP are required, and the OTP is invalid.
/// </summary> /// </summary>

View File

@@ -22,8 +22,7 @@ public class SendEmailOtpRequestValidator(
private static readonly Dictionary<string, string> _sendEmailOtpValidatorErrorDescriptions = new() private static readonly Dictionary<string, string> _sendEmailOtpValidatorErrorDescriptions = new()
{ {
{ SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." }, { SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." },
{ SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent, "email otp sent." }, { SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired, $"{SendAccessConstants.TokenRequest.Email} and {SendAccessConstants.TokenRequest.Otp} are required." },
{ SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, $"{SendAccessConstants.TokenRequest.Email} is invalid." },
{ SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." }, { SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." },
}; };
@@ -33,17 +32,18 @@ public class SendEmailOtpRequestValidator(
// get email // get email
var email = request.Get(SendAccessConstants.TokenRequest.Email); var email = request.Get(SendAccessConstants.TokenRequest.Email);
// It is an invalid request if the email is missing which indicated bad shape. /*
if (string.IsNullOrEmpty(email)) * It is an invalid request if the email is missing or is not in the list of emails in the EmailOtp array.
* This is somewhat contradictory to our process here where a poor shape means invalid_request and invalid
* data is invalid_grant.
* In this case the shape is correct but the data is invalid but to protect against enumeration we treat missing
* or incorrect emails as invalid requests. The response for a request with a correct email which needs an OTP and a request
* that has an invalid email need to be the same otherwise an attacker can enumerate until a valid email is found.
*/
if (string.IsNullOrEmpty(email) || !authMethod.Emails.Contains(email))
{ {
// Request is the wrong shape and doesn't contain an email field. // Request is the wrong shape and doesn't contain an email field.
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired); return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired);
}
// email must be in the list of emails in the EmailOtp array
if (!authMethod.Emails.Contains(email))
{
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid);
} }
// get otp from request // get otp from request
@@ -76,7 +76,7 @@ public class SendEmailOtpRequestValidator(
token, token,
string.Format(SendAccessConstants.OtpEmail.Subject, token)); string.Format(SendAccessConstants.OtpEmail.Subject, token));
} }
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired);
} }
// validate request otp // validate request otp
@@ -100,7 +100,7 @@ public class SendEmailOtpRequestValidator(
switch (error) switch (error)
{ {
case SendAccessConstants.EmailOtpValidatorResults.EmailRequired: case SendAccessConstants.EmailOtpValidatorResults.EmailRequired:
case SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent: case SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired:
return new GrantValidationResult(TokenRequestErrors.InvalidRequest, return new GrantValidationResult(TokenRequestErrors.InvalidRequest,
errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
new Dictionary<string, object> new Dictionary<string, object>
@@ -108,7 +108,6 @@ public class SendEmailOtpRequestValidator(
{ SendAccessConstants.SendAccessError, error } { SendAccessConstants.SendAccessError, error }
}); });
case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid: case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid:
case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid:
return new GrantValidationResult( return new GrantValidationResult(
TokenRequestErrors.InvalidGrant, TokenRequestErrors.InvalidGrant,
errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],

View File

@@ -37,9 +37,7 @@ public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings
errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId;
break; break;
case SendAccessConstants.EnumerationProtection.Email: case SendAccessConstants.EnumerationProtection.Email:
var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null; errorType = SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired;
errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid
: SendAccessConstants.EmailOtpValidatorResults.EmailRequired;
break; break;
case SendAccessConstants.EnumerationProtection.Password: case SendAccessConstants.EnumerationProtection.Password:
var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null; var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null;
@@ -64,8 +62,7 @@ public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings
SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant, SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest,
SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant, SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired => TokenRequestErrors.InvalidRequest,
SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest,
_ => TokenRequestErrors.InvalidGrant _ => TokenRequestErrors.InvalidGrant
}; };

View File

@@ -48,9 +48,8 @@ public class SendConstantsSnapshotTests
public void EmailOtpValidatorResults_Constants_HaveCorrectValues() public void EmailOtpValidatorResults_Constants_HaveCorrectValues()
{ {
// Assert // Assert
Assert.Equal("email_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailInvalid);
Assert.Equal("email_required", SendAccessConstants.EmailOtpValidatorResults.EmailRequired); Assert.Equal("email_required", SendAccessConstants.EmailOtpValidatorResults.EmailRequired);
Assert.Equal("email_and_otp_required_otp_sent", SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); Assert.Equal("email_and_otp_required", SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired);
Assert.Equal("otp_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); Assert.Equal("otp_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid);
Assert.Equal("otp_generation_failed", SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); Assert.Equal("otp_generation_failed", SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed);
} }