Moderate severityNVD Advisory· Published Jan 21, 2025· Updated Feb 12, 2025
Umbraco CMS Vulnerable to User Enumeration Feasible Based On Management API Timing and Response Codes
CVE-2025-24011
Description
Umbraco is a free and open source .NET content management system. Starting in version 14.0.0 and prior to versions 14.3.2 and 15.1.2, it's possible to determine whether an account exists based on an analysis of response codes and timing of Umbraco management API responses. Versions 14.3.2 and 15.1.2 contain a patch. No known workarounds are available.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
Umbraco.CmsNuGet | >= 14.0.0, < 14.3.2 | 14.3.2 |
Umbraco.CmsNuGet | >= 15.0.0, < 15.1.2 | 15.1.2 |
Affected products
1- Range: >= 14.0.0, < 14.3.2
Patches
2559c6c9f312dMerge commit from fork
3 files changed · +272 −57
src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs+83 −57 modified@@ -34,6 +34,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security; [ApiExplorerSettings(IgnoreApi = true)] public class BackOfficeController : SecurityControllerBase { + private static long? _loginDurationAverage; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly IBackOfficeSignInManager _backOfficeSignInManager; private readonly IBackOfficeUserManager _backOfficeUserManager; @@ -75,45 +77,65 @@ public BackOfficeController( [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] public async Task<IActionResult> Login(CancellationToken cancellationToken, LoginRequestModel model) { - IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync( - model.Username, model.Password, true, true); + // Start a timed scope to ensure failed responses return is a consistent time + var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.Value.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.Value.UserMinimumFailedLoginDurationInMilliseconds); + await using var timedScope = new TimedScope(loginDuration, cancellationToken); - if (result.IsNotAllowed) + IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync(model.Username, model.Password, true, true); + if (result.Succeeded is false) { - return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() - .WithTitle("User is not allowed") - .WithDetail("The operation is not allowed on the user") - .Build()); - } + // TODO: The result should include the user and whether the credentials were valid to avoid these additional checks + BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByNameAsync(model.Username.Trim()); // Align with UmbracoSignInManager and trim username! + if (user is not null && + await _backOfficeUserManager.CheckPasswordAsync(user, model.Password)) + { + // The credentials were correct, so cancel timed scope and provide a more detailed failure response + await timedScope.CancelAsync(); - if (result.IsLockedOut) - { - return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() - .WithTitle("User is locked") - .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") + if (result.IsNotAllowed) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is not allowed") + .WithDetail("The operation is not allowed on the user") + .Build()); + } + + if (result.IsLockedOut) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is locked") + .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") + .Build()); + } + + if (result.RequiresTwoFactor) + { + string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username); + IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(user.Key)).Result.Where(x => x.IsEnabledOnUser).Select(x => x.ProviderName); + + return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel() + { + TwoFactorLoginView = twofactorView, + EnabledTwoFactorProviderNames = enabledProviders + }); + } + } + + return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder() + .WithTitle("Invalid credentials") + .WithDetail("The provided credentials are invalid. User has not been signed in.") .Build()); } - if(result.RequiresTwoFactor) - { - string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username); - BackOfficeIdentityUser? attemptingUser = await _backOfficeUserManager.FindByNameAsync(model.Username); - IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(attemptingUser!.Key)).Result.Where(x=>x.IsEnabledOnUser).Select(x=>x.ProviderName); - return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel() - { - TwoFactorLoginView = twofactorView, - EnabledTwoFactorProviderNames = enabledProviders - }); - } + // Set initial or update average (successful) login duration + _loginDurationAverage = _loginDurationAverage is long average + ? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2 + : (long)timedScope.Elapsed.TotalMilliseconds; - if (result.Succeeded) - { - return Ok(); - } - return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder() - .WithTitle("Invalid credentials") - .WithDetail("The provided credentials are invalid. User has not been signed in.") - .Build()); + // Cancel the timed scope (we don't want to unnecessarily wait on a successful response) + await timedScope.CancelAsync(); + + return Ok(); } [AllowAnonymous] @@ -171,7 +193,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken) { return BadRequest(new OpenIddictResponse { - Error = "No context found", ErrorDescription = "Unable to obtain context from the current request." + Error = "No context found", + ErrorDescription = "Unable to obtain context from the current request." }); } @@ -180,7 +203,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken) { return BadRequest(new OpenIddictResponse { - Error = "Invalid 'client ID'", ErrorDescription = "The specified 'client_id' is not valid." + Error = "Invalid 'client ID'", + ErrorDescription = "The specified 'client_id' is not valid." }); } @@ -200,7 +224,8 @@ public async Task<IActionResult> Token() { return BadRequest(new OpenIddictResponse { - Error = "No context found", ErrorDescription = "Unable to obtain context from the current request." + Error = "No context found", + ErrorDescription = "Unable to obtain context from the current request." }); } @@ -213,35 +238,36 @@ public async Task<IActionResult> Token() ? new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, authenticateResult.Principal) : BadRequest(new OpenIddictResponse { - Error = "Authorization failed", ErrorDescription = "The supplied authorization could not be verified." + Error = "Authorization failed", + ErrorDescription = "The supplied authorization could not be verified." }); } - if (request.IsClientCredentialsGrantType()) + // ensure the client ID and secret are valid (verified by OpenIddict) + if (!request.IsClientCredentialsGrantType()) { - // if we get here, the client ID and secret are valid (verified by OpenIddict) - - // grab the user associated with the client ID - BackOfficeIdentityUser? associatedUser = await _backOfficeUserClientCredentialsManager.FindUserAsync(request.ClientId!); - - if (associatedUser is not null) - { - // log current datetime as last login (this also ensures that the user is not flagged as inactive) - associatedUser.LastLoginDateUtc = DateTime.UtcNow; - await _backOfficeUserManager.UpdateAsync(associatedUser); + throw new InvalidOperationException("The requested grant type is not supported."); + } - return await SignInBackOfficeUser(associatedUser, request); - } + // grab the user associated with the client ID + BackOfficeIdentityUser? associatedUser = await _backOfficeUserClientCredentialsManager.FindUserAsync(request.ClientId!); + if (associatedUser is not null) + { + // log current datetime as last login (this also ensures that the user is not flagged as inactive) + associatedUser.LastLoginDateUtc = DateTime.UtcNow; + await _backOfficeUserManager.UpdateAsync(associatedUser); - // if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users - _logger.LogError("The user associated with the client ID ({clientId}) could not be found", request.ClientId); - return BadRequest(new OpenIddictResponse - { - Error = "Authorization failed", ErrorDescription = "The user associated with the supplied 'client_id' could not be found." - }); + return await SignInBackOfficeUser(associatedUser, request); } - throw new InvalidOperationException("The requested grant type is not supported."); + // if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users + _logger.LogError("The user associated with the client ID ({clientId}) could not be found", request.ClientId); + + return BadRequest(new OpenIddictResponse + { + Error = "Authorization failed", + ErrorDescription = "The user associated with the supplied 'client_id' could not be found." + }); } [AllowAnonymous] @@ -489,7 +515,7 @@ private async Task<IActionResult> SignInBackOfficeUser(BackOfficeIdentityUser ba private static IActionResult DefaultChallengeResult() => new ChallengeResult(Constants.Security.BackOfficeAuthenticationType); - private RedirectResult CallbackErrorRedirectWithStatus( string flowType, string status, IEnumerable<IdentityError> identityErrors) + private RedirectResult CallbackErrorRedirectWithStatus(string flowType, string status, IEnumerable<IdentityError> identityErrors) { var redirectUrl = _securitySettings.Value.BackOfficeHost + "/" + _securitySettings.Value.AuthorizeCallbackErrorPathName.TrimStart('/').AppendQueryStringToUrl(
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; @@ -25,6 +26,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; internal const string StaticAuthorizeCallbackPathName = "/umbraco/oauth_complete"; internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout"; internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error"; @@ -101,6 +104,30 @@ public class SecuritySettings [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; + /// <summary> /// Gets or sets a value of the back-office host URI. Use this when running the back-office client and the Management API on different hosts. Leave empty when running both on the same host. /// </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; + } +}
839b6816f2aeMerge commit from fork
3 files changed · +272 −57
src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs+83 −57 modified@@ -34,6 +34,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security; [ApiExplorerSettings(IgnoreApi = true)] public class BackOfficeController : SecurityControllerBase { + private static long? _loginDurationAverage; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly IBackOfficeSignInManager _backOfficeSignInManager; private readonly IBackOfficeUserManager _backOfficeUserManager; @@ -75,45 +77,65 @@ public BackOfficeController( [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] public async Task<IActionResult> Login(CancellationToken cancellationToken, LoginRequestModel model) { - IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync( - model.Username, model.Password, true, true); + // Start a timed scope to ensure failed responses return is a consistent time + var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.Value.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.Value.UserMinimumFailedLoginDurationInMilliseconds); + await using var timedScope = new TimedScope(loginDuration, cancellationToken); - if (result.IsNotAllowed) + IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync(model.Username, model.Password, true, true); + if (result.Succeeded is false) { - return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() - .WithTitle("User is not allowed") - .WithDetail("The operation is not allowed on the user") - .Build()); - } + // TODO: The result should include the user and whether the credentials were valid to avoid these additional checks + BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByNameAsync(model.Username.Trim()); // Align with UmbracoSignInManager and trim username! + if (user is not null && + await _backOfficeUserManager.CheckPasswordAsync(user, model.Password)) + { + // The credentials were correct, so cancel timed scope and provide a more detailed failure response + await timedScope.CancelAsync(); - if (result.IsLockedOut) - { - return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() - .WithTitle("User is locked") - .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") + if (result.IsNotAllowed) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is not allowed") + .WithDetail("The operation is not allowed on the user") + .Build()); + } + + if (result.IsLockedOut) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is locked") + .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") + .Build()); + } + + if (result.RequiresTwoFactor) + { + string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username); + IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(user.Key)).Result.Where(x => x.IsEnabledOnUser).Select(x => x.ProviderName); + + return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel() + { + TwoFactorLoginView = twofactorView, + EnabledTwoFactorProviderNames = enabledProviders + }); + } + } + + return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder() + .WithTitle("Invalid credentials") + .WithDetail("The provided credentials are invalid. User has not been signed in.") .Build()); } - if(result.RequiresTwoFactor) - { - string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username); - BackOfficeIdentityUser? attemptingUser = await _backOfficeUserManager.FindByNameAsync(model.Username); - IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(attemptingUser!.Key)).Result.Where(x=>x.IsEnabledOnUser).Select(x=>x.ProviderName); - return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel() - { - TwoFactorLoginView = twofactorView, - EnabledTwoFactorProviderNames = enabledProviders - }); - } + // Set initial or update average (successful) login duration + _loginDurationAverage = _loginDurationAverage is long average + ? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2 + : (long)timedScope.Elapsed.TotalMilliseconds; - if (result.Succeeded) - { - return Ok(); - } - return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder() - .WithTitle("Invalid credentials") - .WithDetail("The provided credentials are invalid. User has not been signed in.") - .Build()); + // Cancel the timed scope (we don't want to unnecessarily wait on a successful response) + await timedScope.CancelAsync(); + + return Ok(); } [AllowAnonymous] @@ -171,7 +193,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken) { return BadRequest(new OpenIddictResponse { - Error = "No context found", ErrorDescription = "Unable to obtain context from the current request." + Error = "No context found", + ErrorDescription = "Unable to obtain context from the current request." }); } @@ -180,7 +203,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken) { return BadRequest(new OpenIddictResponse { - Error = "Invalid 'client ID'", ErrorDescription = "The specified 'client_id' is not valid." + Error = "Invalid 'client ID'", + ErrorDescription = "The specified 'client_id' is not valid." }); } @@ -200,7 +224,8 @@ public async Task<IActionResult> Token() { return BadRequest(new OpenIddictResponse { - Error = "No context found", ErrorDescription = "Unable to obtain context from the current request." + Error = "No context found", + ErrorDescription = "Unable to obtain context from the current request." }); } @@ -213,35 +238,36 @@ public async Task<IActionResult> Token() ? new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, authenticateResult.Principal) : BadRequest(new OpenIddictResponse { - Error = "Authorization failed", ErrorDescription = "The supplied authorization could not be verified." + Error = "Authorization failed", + ErrorDescription = "The supplied authorization could not be verified." }); } - if (request.IsClientCredentialsGrantType()) + // ensure the client ID and secret are valid (verified by OpenIddict) + if (!request.IsClientCredentialsGrantType()) { - // if we get here, the client ID and secret are valid (verified by OpenIddict) - - // grab the user associated with the client ID - BackOfficeIdentityUser? associatedUser = await _backOfficeUserClientCredentialsManager.FindUserAsync(request.ClientId!); - - if (associatedUser is not null) - { - // log current datetime as last login (this also ensures that the user is not flagged as inactive) - associatedUser.LastLoginDateUtc = DateTime.UtcNow; - await _backOfficeUserManager.UpdateAsync(associatedUser); + throw new InvalidOperationException("The requested grant type is not supported."); + } - return await SignInBackOfficeUser(associatedUser, request); - } + // grab the user associated with the client ID + BackOfficeIdentityUser? associatedUser = await _backOfficeUserClientCredentialsManager.FindUserAsync(request.ClientId!); + if (associatedUser is not null) + { + // log current datetime as last login (this also ensures that the user is not flagged as inactive) + associatedUser.LastLoginDateUtc = DateTime.UtcNow; + await _backOfficeUserManager.UpdateAsync(associatedUser); - // if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users - _logger.LogError("The user associated with the client ID ({clientId}) could not be found", request.ClientId); - return BadRequest(new OpenIddictResponse - { - Error = "Authorization failed", ErrorDescription = "The user associated with the supplied 'client_id' could not be found." - }); + return await SignInBackOfficeUser(associatedUser, request); } - throw new InvalidOperationException("The requested grant type is not supported."); + // if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users + _logger.LogError("The user associated with the client ID ({clientId}) could not be found", request.ClientId); + + return BadRequest(new OpenIddictResponse + { + Error = "Authorization failed", + ErrorDescription = "The user associated with the supplied 'client_id' could not be found." + }); } [AllowAnonymous] @@ -489,7 +515,7 @@ private async Task<IActionResult> SignInBackOfficeUser(BackOfficeIdentityUser ba private static IActionResult DefaultChallengeResult() => new ChallengeResult(Constants.Security.BackOfficeAuthenticationType); - private RedirectResult CallbackErrorRedirectWithStatus( string flowType, string status, IEnumerable<IdentityError> identityErrors) + private RedirectResult CallbackErrorRedirectWithStatus(string flowType, string status, IEnumerable<IdentityError> identityErrors) { var redirectUrl = _securitySettings.Value.BackOfficeHost + "/" + _securitySettings.Value.AuthorizeCallbackErrorPathName.TrimStart('/').AppendQueryStringToUrl(
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; @@ -25,6 +26,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; internal const string StaticAuthorizeCallbackPathName = "/umbraco/oauth_complete"; internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout"; internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error"; @@ -101,6 +104,30 @@ public class SecuritySettings [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; + /// <summary> /// Gets or sets a value of the back-office host URI. Use this when running the back-office client and the Management API on different hosts. Leave empty when running both on the same host. /// </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; + } +}
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- github.com/advisories/GHSA-hmg4-wwm5-p999ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-24011ghsaADVISORY
- github.com/umbraco/Umbraco-CMS/commit/559c6c9f312df1d6eb1bde82c4b81c0896da6382ghsax_refsource_MISCWEB
- github.com/umbraco/Umbraco-CMS/commit/839b6816f2ae3e5f54459a0f09dad6b17e2d1e07ghsax_refsource_MISCWEB
- github.com/umbraco/Umbraco-CMS/security/advisories/GHSA-hmg4-wwm5-p999ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.