1
0
mirror of https://github.com/bitwarden/server synced 2026-01-02 16:43:25 +00:00

[PM-22696] send enumeration protection (#6352)

* feat: add static enumeration helper class
* test: add enumeration helper class unit tests

* feat: implement NeverAuthenticateValidator
* test: unit and integration tests SendNeverAuthenticateValidator

* test: use static class for common integration test setup for Send Access unit and integration tests
* test: update tests to use static helper
This commit is contained in:
Ike
2025-09-23 06:38:22 -04:00
committed by GitHub
parent c6f5d5e36e
commit 3b54fea309
19 changed files with 989 additions and 290 deletions

View File

@@ -34,18 +34,18 @@ public static class SendAccessConstants
public const string Otp = "otp";
}
public static class GrantValidatorResults
public static class SendIdGuidValidatorResults
{
/// <summary>
/// The sendId is valid and the request is well formed. Not returned in any response.
/// The <see cref="TokenRequest.SendId"/> in the request is a valid GUID and the request is well formed. Not returned in any response.
/// </summary>
public const string ValidSendGuid = "valid_send_guid";
/// <summary>
/// The sendId is missing from the request.
/// The <see cref="TokenRequest.SendId"/> is missing from the request.
/// </summary>
public const string SendIdRequired = "send_id_required";
/// <summary>
/// The sendId is invalid, does not match a known send.
/// The <see cref="TokenRequest.SendId"/> is invalid, does not match a known send.
/// </summary>
public const string InvalidSendId = "send_id_invalid";
}
@@ -53,11 +53,11 @@ public static class SendAccessConstants
public static class PasswordValidatorResults
{
/// <summary>
/// The passwordHashB64 does not match the send's password hash.
/// The <see cref="TokenRequest.ClientB64HashedPassword"/> does not match the send's password hash.
/// </summary>
public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid";
/// <summary>
/// The passwordHashB64 is missing from the request.
/// The <see cref="TokenRequest.ClientB64HashedPassword"/> is missing from the request.
/// </summary>
public const string RequestPasswordIsRequired = "password_hash_b64_required";
}
@@ -105,4 +105,14 @@ public static class SendAccessConstants
{
public const string Subject = "Your Bitwarden Send verification code is {0}";
}
/// <summary>
/// We use these static strings to help guide the enumeration protection logic.
/// </summary>
public static class EnumerationProtection
{
public const string Guid = "guid";
public const string Password = "password";
public const string Email = "email";
}
}

View File

@@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendAccessGrantValidator(
ISendAuthenticationQuery _sendAuthenticationQuery,
ISendAuthenticationMethodValidator<NeverAuthenticate> _sendNeverAuthenticateValidator,
ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
IFeatureService _featureService)
: IExtensionGrantValidator
IFeatureService _featureService) : IExtensionGrantValidator
{
string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess;
private static readonly Dictionary<string, string>
_sendGrantValidatorErrorDescriptions = new()
private static readonly Dictionary<string, string> _sendGrantValidatorErrorDescriptions = new()
{
{ SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
{ SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
{ SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
{ SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
};
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
// Check the feature flag
@@ -38,7 +36,7 @@ public class SendAccessGrantValidator(
}
var (sendIdGuid, result) = GetRequestSendId(context);
if (result != SendAccessConstants.GrantValidatorResults.ValidSendGuid)
if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid)
{
context.Result = BuildErrorResult(result);
return;
@@ -49,15 +47,10 @@ public class SendAccessGrantValidator(
switch (method)
{
case NeverAuthenticate:
case NeverAuthenticate never:
// null send scenario.
// TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances).
// We should only map to password or email + OTP protected.
// If user submits password guess for a falsely protected send, then we will return invalid password.
// If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email.
context.Result = BuildErrorResult(SendAccessConstants.GrantValidatorResults.InvalidSendId);
context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid);
return;
case NotAuthenticated:
// automatically issue access token
context.Result = BuildBaseSuccessResult(sendIdGuid);
@@ -90,7 +83,7 @@ public class SendAccessGrantValidator(
// if the sendId is null then the request is the wrong shape and the request is invalid
if (sendId == null)
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired);
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired);
}
// the send_id is not null so the request is the correct shape, so we will attempt to parse it
try
@@ -100,20 +93,20 @@ public class SendAccessGrantValidator(
// Guid.Empty indicates an invalid send_id return invalid grant
if (sendGuid == Guid.Empty)
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId);
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId);
}
return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidSendGuid);
return (sendGuid, SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid);
}
catch
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId);
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId);
}
}
/// <summary>
/// Builds an error result for the specified error type.
/// </summary>
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.GrantValidatorResults"/></param>
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.SendIdGuidValidatorResults"/></param>
/// <returns>The error result.</returns>
private static GrantValidationResult BuildErrorResult(string error)
{
@@ -125,12 +118,12 @@ public class SendAccessGrantValidator(
return error switch
{
// Request is the wrong shape
SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult(
SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired => new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
errorDescription: _sendGrantValidatorErrorDescriptions[error],
customResponse),
// Request is correct shape but data is bad
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId => new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
errorDescription: _sendGrantValidatorErrorDescriptions[error],
customResponse),

View File

@@ -0,0 +1,87 @@
using System.Text;
using Bit.Core.Settings;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
/// <summary>
/// This class is used to protect our system from enumeration attacks. This Validator will always return an error result.
/// We hash the SendId Guid passed into the request to select the an error from the list of possible errors. This ensures
/// that the same error is always returned for the same SendId.
/// </summary>
/// <param name="globalSettings">We need access to a hash key to generate the error index.</param>
public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator<NeverAuthenticate>
{
private readonly string[] _errorOptions =
[
SendAccessConstants.EnumerationProtection.Guid,
SendAccessConstants.EnumerationProtection.Password,
SendAccessConstants.EnumerationProtection.Email
];
public Task<GrantValidationResult> ValidateRequestAsync(
ExtensionGrantValidationContext context,
NeverAuthenticate authMethod,
Guid sendId)
{
var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length);
var request = context.Request.Raw;
var errorType = neverAuthenticateError;
switch (neverAuthenticateError)
{
case SendAccessConstants.EnumerationProtection.Guid:
errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId;
break;
case SendAccessConstants.EnumerationProtection.Email:
var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null;
errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid
: SendAccessConstants.EmailOtpValidatorResults.EmailRequired;
break;
case SendAccessConstants.EnumerationProtection.Password:
var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null;
errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch
: SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired;
break;
}
return Task.FromResult(BuildErrorResult(errorType));
}
private static GrantValidationResult BuildErrorResult(string errorType)
{
// Create error response with custom response data
var customResponse = new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, errorType }
};
var requestError = errorType switch
{
SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest,
SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant,
SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest,
_ => TokenRequestErrors.InvalidGrant
};
return new GrantValidationResult(requestError, errorType, customResponse);
}
private string GetErrorIndex(Guid sendId, int range)
{
var salt = sendId.ToString();
byte[] hmacKey = [];
if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey))
{
hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey);
}
var index = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
return _errorOptions[index];
}
}