VYPR
Moderate severityNVD Advisory· Published May 6, 2025· Updated May 6, 2025

Umbraco Makes User Enumeration Feasible Based on Timing of Login Response

CVE-2025-46736

Description

Umbraco is a free and open source .NET content management system. Prior to versions 10.8.10 and 13.8.1, based on an analysis of the timing of post login API responses, it's possible to determine whether an account exists. The issue is patched in versions 10.8.10 and 13.8.1. No known workarounds are available.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
Umbraco.CmsNuGet
>= 11.0.0-rc1, < 13.8.113.8.1
Umbraco.CmsNuGet
< 10.8.1010.8.10

Affected products

1

Patches

2
34709be6cce9

Merge commit from fork

https://github.com/umbraco/Umbraco-CMSAndy ButlandMay 6, 2025via ghsa
5 files changed · +253 25
  • Directory.Build.props+1 1 modified
    @@ -35,7 +35,7 @@
         <EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
       </PropertyGroup>
     
    -  <!-- Calculate version only once for the whole repository -->
    +  <!-- Calculate version only once for the whole repository --> 
       <PropertyGroup>
         <GitVersionBaseDirectory>$(MSBuildThisFileDirectory)</GitVersionBaseDirectory>
       </PropertyGroup>
    
  • src/Umbraco.Core/Configuration/Models/SecuritySettings.cs+27 0 modified
    @@ -2,6 +2,7 @@
     // See LICENSE for more details.
     
     using System.ComponentModel;
    +using System.ComponentModel.DataAnnotations;
     
     namespace Umbraco.Cms.Core.Configuration.Models;
     
    @@ -27,6 +28,8 @@ public class SecuritySettings
     
         internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
         internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
    +    private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
    +    private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
     
         /// <summary>
         ///     Gets or sets a value indicating whether to keep the user logged in.
    @@ -125,4 +128,28 @@ public class SecuritySettings
         /// </summary>
         [DefaultValue(StaticAllowConcurrentLogins)]
         public bool AllowConcurrentLogins { get; set; } = StaticAllowConcurrentLogins;
    +
    +    /// <summary>
    +    /// Gets or sets the default duration (in milliseconds) of failed login attempts.
    +    /// </summary>
    +    /// <value>
    +    /// The default duration (in milliseconds) of failed login attempts.
    +    /// </value>
    +    /// <remarks>
    +    /// The user login endpoint ensures that failed login attempts take at least as long as the average successful login.
    +    /// However, if no successful logins have occurred, this value is used as the default duration.
    +    /// </remarks>
    +    [Range(0, long.MaxValue)]
    +    [DefaultValue(StaticUserDefaultFailedLoginDurationInMilliseconds)]
    +    public long UserDefaultFailedLoginDurationInMilliseconds { get; set; } = StaticUserDefaultFailedLoginDurationInMilliseconds;
    +
    +    /// <summary>
    +    /// Gets or sets the minimum duration (in milliseconds) of failed login attempts.
    +    /// </summary>
    +    /// <value>
    +    /// The minimum duration (in milliseconds) of failed login attempts.
    +    /// </value>
    +    [Range(0, long.MaxValue)]
    +    [DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)]
    +    public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds;
     }
    
  • src/Umbraco.Core/IO/PhysicalFileSystem.cs+1 1 modified
    @@ -358,7 +358,7 @@ public string GetFullPath(string path)
     
                 // nothing prevents us to reach the file, security-wise, yet it is outside
                 // this filesystem's root - throw
    -            throw new UnauthorizedAccessException($"File original: [{originalPath}] full: [{path}] is outside this filesystem's root.");
    +            throw new UnauthorizedAccessException($"Requested path {originalPath} is outside this filesystem's root.");
             }
     
             /// <summary>
    
  • src/Umbraco.Core/TimedScope.cs+162 0 added
    @@ -0,0 +1,162 @@
    +namespace Umbraco.Cms.Core;
    +
    +/// <summary>
    +/// Makes a code block timed (take at least a certain amount of time). This class cannot be inherited.
    +/// </summary>
    +public sealed class TimedScope : IDisposable, IAsyncDisposable
    +{
    +    private readonly TimeSpan _duration;
    +    private readonly TimeProvider _timeProvider;
    +    private readonly CancellationTokenSource _cancellationTokenSource;
    +    private readonly long _startingTimestamp;
    +
    +    /// <summary>
    +    /// Gets the elapsed time.
    +    /// </summary>
    +    /// <value>
    +    /// The elapsed time.
    +    /// </value>
    +    public TimeSpan Elapsed
    +        => _timeProvider.GetElapsedTime(_startingTimestamp);
    +
    +    /// <summary>
    +    /// Gets the remaining time.
    +    /// </summary>
    +    /// <value>
    +    /// The remaining time.
    +    /// </value>
    +    public TimeSpan Remaining
    +        => TryGetRemaining(out TimeSpan remaining) ? remaining : TimeSpan.Zero;
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
    +    public TimedScope(long millisecondsDuration)
    +        : this(TimeSpan.FromMilliseconds(millisecondsDuration))
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
    +    /// <param name="cancellationToken">The cancellation token.</param>
    +    public TimedScope(long millisecondsDuration, CancellationToken cancellationToken)
    +        : this(TimeSpan.FromMilliseconds(millisecondsDuration), cancellationToken)
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
    +    /// <param name="timeProvider">The time provider.</param>
    +    public TimedScope(long millisecondsDuration, TimeProvider timeProvider)
    +        : this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider)
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
    +    /// <param name="timeProvider">The time provider.</param>
    +    /// <param name="cancellationToken">The cancellation token.</param>
    +    public TimedScope(long millisecondsDuration, TimeProvider timeProvider, CancellationToken cancellationToken)
    +        : this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider, cancellationToken)
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope"/> class.
    +    /// </summary>
    +    /// <param name="duration">The duration the scope should at least take.</param>
    +    public TimedScope(TimeSpan duration)
    +        : this(duration, TimeProvider.System)
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="duration">The duration the scope should at least take.</param>
    +    /// <param name="timeProvider">The time provider.</param>
    +    public TimedScope(TimeSpan duration, TimeProvider timeProvider)
    +        : this(duration, timeProvider, new CancellationTokenSource())
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="duration">The duration the scope should at least take.</param>
    +    /// <param name="cancellationToken">The cancellation token.</param>
    +    public TimedScope(TimeSpan duration, CancellationToken cancellationToken)
    +        : this(duration, TimeProvider.System, cancellationToken)
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="duration">The duration the scope should at least take.</param>
    +    /// <param name="timeProvider">The time provider.</param>
    +    /// <param name="cancellationToken">The cancellation token.</param>
    +    public TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationToken cancellationToken)
    +        : this(duration, timeProvider, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
    +    { }
    +
    +    private TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationTokenSource cancellationTokenSource)
    +    {
    +        _duration = duration;
    +        _timeProvider = timeProvider;
    +        _cancellationTokenSource = cancellationTokenSource;
    +        _startingTimestamp = timeProvider.GetTimestamp();
    +    }
    +
    +    /// <summary>
    +    /// Cancels the timed scope.
    +    /// </summary>
    +    public void Cancel()
    +        => _cancellationTokenSource.Cancel();
    +
    +    /// <summary>
    +    /// Cancels the timed scope asynchronously.
    +    /// </summary>
    +    public async Task CancelAsync()
    +        => await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
    +
    +    /// <summary>
    +    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
    +    /// </summary>
    +    /// <remarks>
    +    /// This will block using <see cref="Thread.Sleep(TimeSpan)" /> until the remaining time has elapsed, if not cancelled.
    +    /// </remarks>
    +    public void Dispose()
    +    {
    +        if (_cancellationTokenSource.IsCancellationRequested is false &&
    +            TryGetRemaining(out TimeSpan remaining))
    +        {
    +            Thread.Sleep(remaining);
    +        }
    +    }
    +
    +    /// <summary>
    +    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously.
    +    /// </summary>
    +    /// <returns>
    +    /// A task that represents the asynchronous dispose operation.
    +    /// </returns>
    +    /// <remarks>
    +    /// This will delay using <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)" /> until the remaining time has elapsed, if not cancelled.
    +    /// </remarks>
    +    public async ValueTask DisposeAsync()
    +    {
    +        if (_cancellationTokenSource.IsCancellationRequested is false &&
    +            TryGetRemaining(out TimeSpan remaining))
    +        {
    +            await Task.Delay(remaining, _timeProvider, _cancellationTokenSource.Token).ConfigureAwait(false);
    +        }
    +    }
    +
    +    private bool TryGetRemaining(out TimeSpan remaining)
    +    {
    +        remaining = _duration.Subtract(Elapsed);
    +
    +        return remaining > TimeSpan.Zero;
    +    }
    +}
    
  • src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs+62 23 modified
    @@ -74,6 +74,9 @@ public class AuthenticationController : UmbracoApiControllerBase
         private readonly IUserService _userService;
         private readonly WebRoutingSettings _webRoutingSettings;
     
    +    private const int FailedLoginDurationRandomOffsetInMilliseconds = 100;
    +    private static long? _loginDurationAverage;
    +
         // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here
         [ActivatorUtilitiesConstructor]
         public AuthenticationController(
    @@ -415,42 +418,78 @@ public async Task<bool> IsAuthenticated()
         [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
         public async Task<ActionResult<UserDetail?>> PostLogin(LoginModel loginModel)
         {
    +        // Start a timed scope to ensure failed responses return is a consistent time
    +        await using var timedScope = new TimedScope(GetLoginDuration(), CancellationToken.None);
    +
             // Sign the user in with username/password, this also gives a chance for developers to
             // custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker
             SignInResult result = await _signInManager.PasswordSignInAsync(
                 loginModel.Username, loginModel.Password, true, true);
     
    -        if (result.Succeeded)
    -        {
    -            // return the user detail
    -            return GetUserDetail(_userService.GetByUsername(loginModel.Username));
    -        }
    -
    -        if (result.RequiresTwoFactor)
    +        if (result.Succeeded is false)
             {
    -            var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username);
    +            BackOfficeIdentityUser? user = await _userManager.FindByNameAsync(loginModel.Username.Trim());
     
    -            IUser? attemptedUser = _userService.GetByUsername(loginModel.Username);
    +            if (user is not null &&
    +                await _userManager.CheckPasswordAsync(user, loginModel.Password))
    +            {
    +                // The credentials were correct, so cancel timed scope and provide a more detailed failure response
    +                await timedScope.CancelAsync();
     
    -            // create a with information to display a custom two factor send code view
    -            var verifyResponse =
    -                new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id })
    +                if (result.RequiresTwoFactor)
                     {
    -                    StatusCode = StatusCodes.Status402PaymentRequired
    -                };
    +                    var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username);
    +
    +                    IUser? attemptedUser = _userService.GetByUsername(loginModel.Username);
    +
    +                    // create a with information to display a custom two factor send code view
    +                    var verifyResponse =
    +                        new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id })
    +                        {
    +                            StatusCode = StatusCodes.Status402PaymentRequired
    +                        };
    +
    +                    return verifyResponse;
    +                }
     
    -            return verifyResponse;
    +                // TODO: We can check for these and respond differently if we think it's important
    +                //  result.IsLockedOut
    +                //  result.IsNotAllowed
    +            }
    +
    +            // Return BadRequest (400), we don't want to return a 401 because that get's intercepted
    +            // by our angular helper because it thinks that we need to re-perform the request once we are
    +            // authorized and we don't want to return a 403 because angular will show a warning message indicating
    +            // that the user doesn't have access to perform this function, we just want to return a normal invalid message.
    +            return BadRequest();
             }
     
    -        // TODO: We can check for these and respond differently if we think it's important
    -        //  result.IsLockedOut
    -        //  result.IsNotAllowed
    +        // Set initial or update average (successful) login duration
    +        _loginDurationAverage = _loginDurationAverage is long average
    +            ? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2
    +            : (long)timedScope.Elapsed.TotalMilliseconds;
    +
    +        // Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
    +        await timedScope.CancelAsync();
    +
    +        // Return the user detail
    +        return GetUserDetail(_userService.GetByUsername(loginModel.Username));
    +    }
    +
    +    private long GetLoginDuration()
    +    {
    +        var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.UserMinimumFailedLoginDurationInMilliseconds);
    +        var random = new Random();
    +        var randomDelay = random.Next(-FailedLoginDurationRandomOffsetInMilliseconds, FailedLoginDurationRandomOffsetInMilliseconds);
    +        loginDuration += randomDelay;
    +
    +        // Just be sure we don't get a negative number - possible if someone has configured a very low UserMinimumFailedLoginDurationInMilliseconds value.
    +        if (loginDuration < 0)
    +        {
    +            loginDuration = 0;
    +        }
     
    -        // return BadRequest (400), we don't want to return a 401 because that get's intercepted
    -        // by our angular helper because it thinks that we need to re-perform the request once we are
    -        // authorized and we don't want to return a 403 because angular will show a warning message indicating
    -        // that the user doesn't have access to perform this function, we just want to return a normal invalid message.
    -        return BadRequest();
    +        return loginDuration;
         }
     
         /// <summary>
    
14fbd20665b4

Merge commit from fork

https://github.com/umbraco/Umbraco-CMSAndy ButlandMay 6, 2025via ghsa
6 files changed · +214 29
  • Directory.Build.props+1 0 modified
    @@ -49,4 +49,5 @@
       <PropertyGroup>
         <GitVersionBaseDirectory>$(MSBuildThisFileDirectory)</GitVersionBaseDirectory>
       </PropertyGroup>
    +
     </Project>
    
  • src/Umbraco.Core/Configuration/Models/SecuritySettings.cs+27 0 modified
    @@ -2,6 +2,7 @@
     // See LICENSE for more details.
     
     using System.ComponentModel;
    +using System.ComponentModel.DataAnnotations;
     
     namespace Umbraco.Cms.Core.Configuration.Models;
     
    @@ -24,6 +25,8 @@ public class SecuritySettings
     
         internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
         internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
    +    private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
    +    private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
     
         /// <summary>
         ///     Gets or sets a value indicating whether to keep the user logged in.
    @@ -109,4 +112,28 @@ public class SecuritySettings
         [Obsolete("Use ContentSettings.AllowEditFromInvariant instead")]
         [DefaultValue(StaticAllowEditInvariantFromNonDefault)]
         public bool AllowEditInvariantFromNonDefault { get; set; } = StaticAllowEditInvariantFromNonDefault;
    +
    +    /// <summary>
    +    /// Gets or sets the default duration (in milliseconds) of failed login attempts.
    +    /// </summary>
    +    /// <value>
    +    /// The default duration (in milliseconds) of failed login attempts.
    +    /// </value>
    +    /// <remarks>
    +    /// The user login endpoint ensures that failed login attempts take at least as long as the average successful login.
    +    /// However, if no successful logins have occurred, this value is used as the default duration.
    +    /// </remarks>
    +    [Range(0, long.MaxValue)]
    +    [DefaultValue(StaticUserDefaultFailedLoginDurationInMilliseconds)]
    +    public long UserDefaultFailedLoginDurationInMilliseconds { get; set; } = StaticUserDefaultFailedLoginDurationInMilliseconds;
    +
    +    /// <summary>
    +    /// Gets or sets the minimum duration (in milliseconds) of failed login attempts.
    +    /// </summary>
    +    /// <value>
    +    /// The minimum duration (in milliseconds) of failed login attempts.
    +    /// </value>
    +    [Range(0, long.MaxValue)]
    +    [DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)]
    +    public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds;
     }
    
  • src/Umbraco.Core/IO/PhysicalFileSystem.cs+1 1 modified
    @@ -358,7 +358,7 @@ public string GetFullPath(string path)
     
                 // nothing prevents us to reach the file, security-wise, yet it is outside
                 // this filesystem's root - throw
    -            throw new UnauthorizedAccessException($"File original: [{originalPath}] full: [{path}] is outside this filesystem's root.");
    +            throw new UnauthorizedAccessException($"Requested path {originalPath} is outside this filesystem's root.");
             }
     
             /// <summary>
    
  • src/Umbraco.Core/TimedScope.cs+118 0 added
    @@ -0,0 +1,118 @@
    +using System.Diagnostics;
    +
    +namespace Umbraco.Cms.Core;
    +
    +/// <summary>
    +/// Makes a code block timed (take at least a certain amount of time). This class cannot be inherited.
    +/// </summary>
    +public sealed class TimedScope : IDisposable, IAsyncDisposable
    +{
    +    private readonly TimeSpan _duration;
    +    private readonly CancellationTokenSource _cancellationTokenSource;
    +    private readonly Stopwatch _stopwatch;
    +
    +    /// <summary>
    +    /// Gets the elapsed time.
    +    /// </summary>
    +    /// <value>
    +    /// The elapsed time.
    +    /// </value>
    +    public TimeSpan Elapsed => _stopwatch.Elapsed;
    +
    +    /// <summary>
    +    /// Gets the remaining time.
    +    /// </summary>
    +    /// <value>
    +    /// The remaining time.
    +    /// </value>
    +    public TimeSpan Remaining
    +        => TryGetRemaining(out TimeSpan remaining) ? remaining : TimeSpan.Zero;
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
    +    public TimedScope(long millisecondsDuration)
    +        : this(TimeSpan.FromMilliseconds(millisecondsDuration))
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
    +    /// <param name="cancellationToken">The cancellation token.</param>
    +    public TimedScope(long millisecondsDuration, CancellationToken cancellationToken)
    +        : this(TimeSpan.FromMilliseconds(millisecondsDuration), cancellationToken)
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope"/> class.
    +    /// </summary>
    +    /// <param name="duration">The duration the scope should at least take.</param>
    +    public TimedScope(TimeSpan duration)
    +        : this(duration, new CancellationTokenSource())
    +    { }
    +
    +    /// <summary>
    +    /// Initializes a new instance of the <see cref="TimedScope" /> class.
    +    /// </summary>
    +    /// <param name="duration">The duration the scope should at least take.</param>
    +    /// <param name="cancellationToken">The cancellation token.</param>
    +    public TimedScope(TimeSpan duration, CancellationToken cancellationToken)
    +        : this(duration, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
    +    { }
    +
    +    private TimedScope(TimeSpan duration, CancellationTokenSource cancellationTokenSource)
    +    {
    +        _duration = duration;
    +        _cancellationTokenSource = cancellationTokenSource;
    +        _stopwatch = new Stopwatch();
    +        _stopwatch.Start();
    +    }
    +
    +    /// <summary>
    +    /// Cancels the timed scope.
    +    /// </summary>
    +    public void Cancel()
    +        => _cancellationTokenSource.Cancel();
    +
    +    /// <summary>
    +    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
    +    /// </summary>
    +    /// <remarks>
    +    /// This will block using <see cref="Thread.Sleep(TimeSpan)" /> until the remaining time has elapsed, if not cancelled.
    +    /// </remarks>
    +    public void Dispose()
    +    {
    +        if (_cancellationTokenSource.IsCancellationRequested is false &&
    +            TryGetRemaining(out TimeSpan remaining))
    +        {
    +            Thread.Sleep(remaining);
    +        }
    +    }
    +
    +    /// <summary>
    +    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously.
    +    /// </summary>
    +    /// <returns>
    +    /// A task that represents the asynchronous dispose operation.
    +    /// </returns>
    +    /// <remarks>
    +    /// This will delay using <see cref="Task.Delay(TimeSpan, CancellationToken)" /> until the remaining time has elapsed, if not cancelled.
    +    /// </remarks>
    +    public async ValueTask DisposeAsync()
    +    {
    +        if (_cancellationTokenSource.IsCancellationRequested is false &&
    +            TryGetRemaining(out TimeSpan remaining))
    +        {
    +            await Task.Delay(remaining, _cancellationTokenSource.Token).ConfigureAwait(false);
    +        }
    +    }
    +
    +    private bool TryGetRemaining(out TimeSpan remaining)
    +    {
    +        remaining = _duration.Subtract(Elapsed);
    +
    +        return remaining > TimeSpan.Zero;
    +    }
    +}
    
  • src/Umbraco.Core/Umbraco.Core.csproj+1 1 modified
    @@ -1,4 +1,4 @@
    -<Project Sdk="Microsoft.NET.Sdk">
    +<Project Sdk="Microsoft.NET.Sdk">
       <PropertyGroup>
         <PackageId>Umbraco.Cms.Core</PackageId>
         <Title>Umbraco CMS - Core</Title>
    
  • src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs+66 27 modified
    @@ -74,6 +74,9 @@ public class AuthenticationController : UmbracoApiControllerBase
         private readonly IUserService _userService;
         private readonly WebRoutingSettings _webRoutingSettings;
     
    +    private const int FailedLoginDurationRandomOffsetInMilliseconds = 100;
    +    private static long? _loginDurationAverage;
    +
         // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here
         [ActivatorUtilitiesConstructor]
         public AuthenticationController(
    @@ -342,47 +345,83 @@ public async Task<bool> IsAuthenticated()
         [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
         public async Task<ActionResult<UserDetail?>> PostLogin(LoginModel loginModel)
         {
    +        // Start a timed scope to ensure failed responses return is a consistent time
    +        await using var timedScope = new TimedScope(GetLoginDuration(), CancellationToken.None);
    +
             // Sign the user in with username/password, this also gives a chance for developers to
             // custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker
             SignInResult result = await _signInManager.PasswordSignInAsync(
                 loginModel.Username, loginModel.Password, true, true);
     
    -        if (result.Succeeded)
    +        if (result.Succeeded is false)
             {
    -            // return the user detail
    -            return GetUserDetail(_userService.GetByUsername(loginModel.Username));
    -        }
    +            BackOfficeIdentityUser? user = await _userManager.FindByNameAsync(loginModel.Username.Trim());
     
    -        if (result.RequiresTwoFactor)
    -        {
    -            var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username);
    -            if (twofactorView.IsNullOrWhiteSpace())
    +            if (user is not null &&
    +                await _userManager.CheckPasswordAsync(user, loginModel.Password))
                 {
    -                return new ValidationErrorResult(
    -                    $"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth ");
    -            }
    -
    -            IUser? attemptedUser = _userService.GetByUsername(loginModel.Username);
    +                // The credentials were correct, so cancel timed scope and provide a more detailed failure response
    +                timedScope.Cancel();
     
    -            // create a with information to display a custom two factor send code view
    -            var verifyResponse =
    -                new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id })
    +                if (result.RequiresTwoFactor)
                     {
    -                    StatusCode = StatusCodes.Status402PaymentRequired
    -                };
    +                    var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username);
    +                    if (twofactorView.IsNullOrWhiteSpace())
    +                    {
    +                        return new ValidationErrorResult(
    +                            $"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth ");
    +                    }
    +
    +                    IUser? attemptedUser = _userService.GetByUsername(loginModel.Username);
    +
    +                    // create a with information to display a custom two factor send code view
    +                    var verifyResponse =
    +                        new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id })
    +                        {
    +                            StatusCode = StatusCodes.Status402PaymentRequired
    +                        };
    +
    +                    return verifyResponse;
    +                }
    +
    +                // TODO: We can check for these and respond differently if we think it's important
    +                //  result.IsLockedOut
    +                //  result.IsNotAllowed
    +            }
     
    -            return verifyResponse;
    +            // Return BadRequest (400), we don't want to return a 401 because that get's intercepted
    +            // by our angular helper because it thinks that we need to re-perform the request once we are
    +            // authorized and we don't want to return a 403 because angular will show a warning message indicating
    +            // that the user doesn't have access to perform this function, we just want to return a normal invalid message.
    +            return BadRequest();
             }
     
    -        // TODO: We can check for these and respond differently if we think it's important
    -        //  result.IsLockedOut
    -        //  result.IsNotAllowed
    +        // Set initial or update average (successful) login duration
    +        _loginDurationAverage = _loginDurationAverage is long average
    +            ? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2
    +            : (long)timedScope.Elapsed.TotalMilliseconds;
    +
    +        // Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
    +        timedScope.Cancel();
    +
    +        // Return the user detail
    +        return GetUserDetail(_userService.GetByUsername(loginModel.Username));
    +    }
    +
    +    private long GetLoginDuration()
    +    {
    +        var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.UserMinimumFailedLoginDurationInMilliseconds);
    +        var random = new Random();
    +        var randomDelay = random.Next(-FailedLoginDurationRandomOffsetInMilliseconds, FailedLoginDurationRandomOffsetInMilliseconds);
    +        loginDuration += randomDelay;
    +
    +        // Just be sure we don't get a negative number - possible if someone has configured a very low UserMinimumFailedLoginDurationInMilliseconds value.
    +        if (loginDuration < 0)
    +        {
    +            loginDuration = 0;
    +        }
     
    -        // return BadRequest (400), we don't want to return a 401 because that get's intercepted
    -        // by our angular helper because it thinks that we need to re-perform the request once we are
    -        // authorized and we don't want to return a 403 because angular will show a warning message indicating
    -        // that the user doesn't have access to perform this function, we just want to return a normal invalid message.
    -        return BadRequest();
    +        return loginDuration;
         }
     
         /// <summary>
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.