Compare commits

...
Author SHA1 Message Date
Andy Butland c6ab394947 Merge branch 'release/10.8.11' into v10/dev
# Conflicts:
#	version.json
2025-07-02 06:32:18 +02:00
Kenn JacobsenandGitHub d8f68d2c40 Merge commit from fork 2025-06-24 08:39:17 +02:00
Andy Butland ff88617db0 Bumped version to 10.8.11. 2025-06-05 07:49:09 +02:00
Andy Butland 9f912aea0e Merge branch 'release/10.8.10' into v10/dev
# Conflicts:
#	version.json
2025-05-06 07:15:10 +02:00
Andy Butland ba95c12f09 Resolved failures in unit tests. 2025-05-06 05:37:30 +02:00
Andy ButlandandGitHub 14fbd20665 Merge commit from fork
* Backport user enumeration fix.

* Supress warning on use of .NET 6 with TimeProvider dependency.

* Replace TimeProvider with Stopwatch, as the former isn't tested against .NET 6.0 and generates warnings.

* Remove full path details from exception when requesting a path outside of the physical file system's root.

* Added randomness to login duration.

* Ensured against negative duration.
2025-05-06 05:11:03 +02:00
mole 2d8b5e8786 Use windows agent for nuget push 2025-04-28 10:48:26 +02:00
Andy Butland 747e095178 Bump version to 10.8.10. 2025-04-22 10:06:27 +02:00
Andy Butland 1cfa5a225e Merge branch 'release/10.8.9' into v10/dev
# Conflicts:
#	version.json
2025-03-11 09:20:51 +01:00
7888b9a4ce Merge commit from fork
* Bumped version to 10.8.9.

* Fixed parsing of node if in content and media permission querystring handlers to retrieve expected value when multiple are provided in the querystring.

# Conflicts:
#	tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandlerTests.cs
#	tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringHandlerTests.cs

* Add HttpPost attributes to backoffice endpoints that should only accept post requests.

* Narrow PermissionQueryString parsing to the releveant UmbracoObjectType

* Add missed update from v10

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
2025-03-11 05:11:08 +01:00
Andy Butland e31582b297 Backport bumped imagesharp to prevent CVE-2025-27598 #18602 2025-03-09 08:58:25 +01:00
Nikolaj Geisle 75cc017a18 Merge branch 'release/10.8.8' into v10/dev
# Conflicts:
#	version.json
2025-01-23 11:08:09 +01:00
d60137e6da Merge commit from fork
* Ensure preview can only be requested with a valid culture code.

* Update src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>

* Restricted to predefined culture codes.

---------

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>
2025-01-20 14:14:28 +01:00
Andy Butland 9f9c88781a Bumped version to 10.8.8. 2025-01-07 10:08:01 +01:00
20 changed files with 387 additions and 68 deletions
+1
View File
@@ -49,4 +49,5 @@
<PropertyGroup>
<GitVersionBaseDirectory>$(MSBuildThisFileDirectory)</GitVersionBaseDirectory>
</PropertyGroup>
</Project>
+4
View File
@@ -533,6 +533,8 @@ stages:
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.myGetDeploy}}))
jobs:
- job:
pool:
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
displayName: Push to pre-release feed
steps:
- checkout: none
@@ -559,6 +561,8 @@ stages:
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
jobs:
- job:
pool:
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
displayName: Push to NuGet
steps:
- checkout: none
+1 -1
View File
@@ -48,7 +48,7 @@
<PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.Map" Version="1.0.2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="2.1.9" />
<PackageVersion Include="SixLabors.ImageSharp" Version="2.1.10" />
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="2.0.2" />
<PackageVersion Include="Smidge.InMemory" Version="4.3.0" />
<PackageVersion Include="Smidge.Nuglify" Version="4.2.1" />
@@ -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,26 @@ 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>
[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>
[DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)]
public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds;
}
+1 -1
View File
@@ -358,7 +358,7 @@ namespace Umbraco.Cms.Core.IO
// 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>
+118
View File
@@ -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;
}
}
+1 -1
View File
@@ -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>
@@ -19,6 +19,8 @@ public class
{
private readonly ContentPermissions _contentPermissions;
protected override UmbracoObjectTypes KeyParsingFilterType => UmbracoObjectTypes.Document;
/// <summary>
/// Initializes a new instance of the <see cref="ContentPermissionsQueryStringHandler" /> class.
/// </summary>
@@ -47,7 +49,11 @@ public class
return Task.FromResult(true);
}
var argument = routeVal.ToString();
// Handle case where the incoming querystring could contain more than one value (e.g. ?id=1000&id=1001).
// It's the first one that'll be processed by the protected method so we should verify that.
var argument = routeVal.Count == 1
? routeVal.ToString()
: routeVal.FirstOrDefault()?.ToString() ?? string.Empty;
if (!TryParseNodeId(argument, out nodeId))
{
@@ -18,6 +18,8 @@ public class MediaPermissionsQueryStringHandler : PermissionsQueryStringHandler<
{
private readonly MediaPermissions _mediaPermissions;
protected override UmbracoObjectTypes KeyParsingFilterType => UmbracoObjectTypes.Media;
/// <summary>
/// Initializes a new instance of the <see cref="MediaPermissionsQueryStringHandler" /> class.
/// </summary>
@@ -44,7 +46,11 @@ public class MediaPermissionsQueryStringHandler : PermissionsQueryStringHandler<
return Task.FromResult(true);
}
var argument = routeVal.ToString();
// Handle case where the incoming querystring could contain more than one value (e.g. ?id=1000&id=1001).
// It's the first one that'll be processed by the protected method so we should verify that.
var argument = routeVal.Count == 1
? routeVal.ToString()
: routeVal.FirstOrDefault()?.ToString() ?? string.Empty;
if (!TryParseNodeId(argument, out var nodeId))
{
@@ -49,12 +49,18 @@ public abstract class PermissionsQueryStringHandler<T> : MustSatisfyRequirementA
/// </summary>
protected IEntityService EntityService { get; set; }
/// <summary>
/// Defaults to Unknown so all types are allowed, since Keys are unique across all node types this works,
/// but it if you are certain you are looking for a specific type this should be overwritten for DB query performance.
/// </summary>
protected virtual UmbracoObjectTypes KeyParsingFilterType => UmbracoObjectTypes.Unknown;
/// <summary>
/// Attempts to parse a node ID from a string representation found in a querystring value.
/// </summary>
/// <param name="argument">Querystring value.</param>
/// <param name="nodeId">Output parsed Id.</param>
/// <returns>True of node ID could be parased, false it not.</returns>
/// <returns>True of node ID could be parsed, false it not.</returns>
protected bool TryParseNodeId(string argument, out int nodeId)
{
// If the argument is an int, it will parse and can be assigned to nodeId.
@@ -75,7 +81,7 @@ public abstract class PermissionsQueryStringHandler<T> : MustSatisfyRequirementA
if (Guid.TryParse(argument, out Guid key))
{
nodeId = EntityService.GetId(key, UmbracoObjectTypes.Document).Result;
nodeId = EntityService.GetId(key, KeyParsingFilterType).Result;
return true;
}
@@ -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(
@@ -129,12 +132,17 @@ public class AuthenticationController : UmbracoApiControllerBase
AuthorizationPolicies.BackOfficeAccess)] // Needed to enforce the principle set on the request, if one exists.
public IDictionary<string, object> GetPasswordConfig(int userId)
{
if (HttpContext.HasActivePasswordResetFlowSession(userId))
{
return _passwordConfiguration.GetConfiguration();
}
Attempt<int> currentUserId =
_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId() ?? Attempt<int>.Fail();
return _passwordConfiguration.GetConfiguration(
currentUserId.Success
? currentUserId.Result != userId
: true);
return currentUserId.Success
? _passwordConfiguration.GetConfiguration(currentUserId.Result != userId)
: new Dictionary<string, object>();
}
/// <summary>
@@ -342,47 +350,85 @@ public class AuthenticationController : UmbracoApiControllerBase
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
public async Task<ActionResult<UserDetail?>> PostLogin(LoginModel loginModel)
{
HttpContext.EndPasswordResetFlowSession();
// 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 ");
// The credentials were correct, so cancel timed scope and provide a more detailed failure response
timedScope.Cancel();
if (result.RequiresTwoFactor)
{
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
}
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 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;
// 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();
// 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 loginDuration;
}
/// <summary>
@@ -401,6 +447,8 @@ public class AuthenticationController : UmbracoApiControllerBase
return BadRequest();
}
HttpContext.EndPasswordResetFlowSession();
BackOfficeIdentityUser? identityUser = await _userManager.FindByEmailAsync(model.Email);
await Task.Delay(RandomNumberGenerator.GetInt32(400, 2500)); // To randomize response time preventing user enumeration
@@ -554,6 +602,8 @@ public class AuthenticationController : UmbracoApiControllerBase
[AllowAnonymous]
public async Task<IActionResult> PostSetPassword(SetPasswordModel model)
{
HttpContext.EndPasswordResetFlowSession();
BackOfficeIdentityUser? identityUser =
await _userManager.FindByIdAsync(model.UserId.ToString(CultureInfo.InvariantCulture));
@@ -370,6 +370,8 @@ public class BackOfficeController : UmbracoController
var result = await _userManager.VerifyUserTokenAsync(user, "Default", "ResetPassword", resetCode);
if (result)
{
HttpContext.StartPasswordResetFlowSession(userId);
//Add a flag and redirect for it to be displayed
TempData[ViewDataExtensions.TokenPasswordResetCode] =
_jsonSerializer.Serialize(
@@ -196,6 +196,7 @@ public class ContentController : ContentControllerBase
/// Permission check is done for letter 'R' which is for <see cref="ActionRights" /> which the user must have access to
/// update
/// </remarks>
[HttpPost]
public async Task<ActionResult<IEnumerable<AssignedUserGroupPermissions?>?>> PostSaveUserGroupPermissions(
UserGroupPermissionsSave saveModel)
{
@@ -842,6 +843,7 @@ public class ContentController : ContentControllerBase
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)]
[FileUploadCleanupFilter]
[ContentSaveValidation(skipUserAccessValidation:true)] // skip user access validation because we "only" require Settings access to create new blueprints from scratch
[HttpPost]
public async Task<ActionResult<ContentItemDisplay<ContentVariantDisplay>?>?> PostSaveBlueprint(
[ModelBinder(typeof(BlueprintItemBinder))] ContentItemSave contentItem)
{
@@ -879,6 +881,7 @@ public class ContentController : ContentControllerBase
[FileUploadCleanupFilter]
[ContentSaveValidation]
[OutgoingEditorModelEvent]
[HttpPost]
public async Task<ActionResult<ContentItemDisplay<ContentVariantScheduleDisplay>?>> PostSave(
[ModelBinder(typeof(ContentItemBinder))] ContentItemSave contentItem)
{
@@ -1960,6 +1963,7 @@ public class ContentController : ContentControllerBase
/// does not have Publish access to this node.
/// </remarks>
[Authorize(Policy = AuthorizationPolicies.ContentPermissionPublishById)]
[HttpPost]
public IActionResult PostPublishById(int id)
{
IContent? foundContent = GetObjectFromRequest(() => _contentService.GetById(id));
@@ -1991,6 +1995,7 @@ public class ContentController : ContentControllerBase
/// does not have Publish access to this node.
/// </remarks>
[Authorize(Policy = AuthorizationPolicies.ContentPermissionPublishById)]
[HttpPost]
public IActionResult PostPublishByIdAndCulture(PublishContent model)
{
var languageCount = _allLangs.Value.Count();
@@ -2114,6 +2119,7 @@ public class ContentController : ContentControllerBase
/// </summary>
/// <param name="sorted"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> PostSort(ContentSortOrder sorted)
{
if (sorted == null)
@@ -2165,6 +2171,7 @@ public class ContentController : ContentControllerBase
/// </summary>
/// <param name="move"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult?> PostMove(MoveOrCopy move)
{
// Authorize...
@@ -2199,6 +2206,7 @@ public class ContentController : ContentControllerBase
/// </summary>
/// <param name="copy"></param>
/// <returns></returns>
[HttpPost]
public async Task<ActionResult<IContent>?> PostCopy(MoveOrCopy copy)
{
// Authorize...
@@ -2238,6 +2246,7 @@ public class ContentController : ContentControllerBase
/// <param name="model">The content and variants to unpublish</param>
/// <returns></returns>
[OutgoingEditorModelEvent]
[HttpPost]
public async Task<ActionResult<ContentItemDisplayWithSchedule?>> PostUnpublish(UnpublishContent model)
{
IContent? foundContent = _contentService.GetById(model.Id);
@@ -2960,6 +2969,7 @@ public class ContentController : ContentControllerBase
return notifications;
}
[HttpPost]
public IActionResult PostNotificationOptions(
int contentId,
[FromQuery(Name = "notifyOptions[]")] string[] notifyOptions)
@@ -388,6 +388,7 @@ public class MediaController : ContentControllerBase
/// </summary>
/// <param name="move"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> PostMove(MoveOrCopy move)
{
// Authorize...
@@ -439,6 +440,7 @@ public class MediaController : ContentControllerBase
[FileUploadCleanupFilter]
[MediaItemSaveValidation]
[OutgoingEditorModelEvent]
[HttpPost]
public ActionResult<MediaItemDisplay?>? PostSave(
[ModelBinder(typeof(MediaItemBinder))] MediaItemSave contentItem)
{
@@ -554,6 +556,7 @@ public class MediaController : ContentControllerBase
/// </summary>
/// <param name="sorted"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> PostSort(ContentSortOrder sorted)
{
if (sorted == null)
@@ -599,6 +602,7 @@ public class MediaController : ContentControllerBase
}
}
[HttpPost]
public async Task<ActionResult<MediaItemDisplay?>> PostAddFolder(PostedFolder folder)
{
ActionResult<int?>? parentIdResult = await GetParentIdAsIntAsync(folder.ParentId, true);
@@ -632,6 +636,7 @@ public class MediaController : ContentControllerBase
/// <remarks>
/// We cannot validate this request with attributes (nicely) due to the nature of the multi-part for data.
/// </remarks>
[HttpPost]
public async Task<IActionResult> PostAddFile([FromForm] string path, [FromForm] string currentFolder,
[FromForm] string contentTypeAlias, List<IFormFile> file)
{
@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewEngines;
@@ -130,6 +131,11 @@ public class PreviewController : Controller
[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
public ActionResult Frame(int id, string culture)
{
if (ValidateProvidedCulture(culture) is false)
{
throw new InvalidOperationException($"Could not recognise the provided culture: {culture}");
}
EnterPreview(id);
// use a numeric URL because content may not be in cache and so .Url would fail
@@ -138,6 +144,28 @@ public class PreviewController : Controller
return RedirectPermanent($"../../{id}{query}");
}
private static bool ValidateProvidedCulture(string culture)
{
if (string.IsNullOrEmpty(culture))
{
return true;
}
// We can be confident the backoffice will have provided a valid culture in linking to the
// preview, so we don't need to check that the culture matches an Umbraco language.
// We are only concerned here with protecting against XSS attacks from a fiddled preview
// URL, so we can just confirm we have a valid culture.
try
{
CultureInfo.GetCultureInfo(culture, true);
return true;
}
catch (CultureNotFoundException)
{
return false;
}
}
public ActionResult? EnterPreview(int id)
{
IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser;
@@ -5,9 +5,20 @@ namespace Umbraco.Extensions;
public static class HttpContextExtensions
{
private const string PasswordResetFlowSessionKey = nameof(PasswordResetFlowSessionKey);
public static void SetExternalLoginProviderErrors(this HttpContext httpContext, BackOfficeExternalLoginProviderErrors errors)
=> httpContext.Items[nameof(BackOfficeExternalLoginProviderErrors)] = errors;
public static BackOfficeExternalLoginProviderErrors? GetExternalLoginProviderErrors(this HttpContext httpContext)
=> httpContext.Items[nameof(BackOfficeExternalLoginProviderErrors)] as BackOfficeExternalLoginProviderErrors;
internal static void StartPasswordResetFlowSession(this HttpContext httpContext, int userId)
=> httpContext.Session.SetInt32(PasswordResetFlowSessionKey, userId);
internal static void EndPasswordResetFlowSession(this HttpContext httpContext)
=> httpContext.Session.Remove(PasswordResetFlowSessionKey);
internal static bool HasActivePasswordResetFlowSession(this HttpContext httpContext, int userId)
=> httpContext.Session.GetInt32(PasswordResetFlowSessionKey) == userId;
}
@@ -141,7 +141,10 @@ public abstract class UmbracoViewPage<TModel> : RazorPage<TModel>
string.Format(
ContentSettings.PreviewBadge,
HostingEnvironment.ToAbsolute(GlobalSettings.UmbracoPath),
Context.Request.GetEncodedUrl(),
System.Web.HttpUtility.HtmlEncode(Context.Request.GetEncodedUrl()), // Belt and braces - via a browser at least it doesn't seem possible to have anything other than
// a valid culture code provided in the querystring of this URL.
// But just to be sure of prevention of an XSS vulnterablity we'll HTML encode here too.
// An expected URL is untouched by this encoding.
UmbracoContext.PublishedRequest?.PublishedContent?.Id);
}
else
@@ -2,9 +2,7 @@
// See LICENSE for more details.
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
@@ -35,7 +33,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Id_From_Requirement_With_Permission_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext(NodeId);
var mockHttpContextAccessor = CreateMockHttpContextAccessor();
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue();
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "A" });
await sut.HandleAsync(authHandlerContext);
@@ -47,7 +45,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Id_From_Requirement_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext(NodeId);
var mockHttpContextAccessor = CreateMockHttpContextAccessor();
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue();
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "B" });
await sut.HandleAsync(authHandlerContext);
@@ -60,7 +58,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Id_Missing_From_Requirement_And_QueryString_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor("xxx");
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue("xxx");
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "A" });
await sut.HandleAsync(authHandlerContext);
@@ -72,7 +70,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Integer_Id_From_QueryString_With_Permission_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: NodeId.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: NodeId.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "A" });
await sut.HandleAsync(authHandlerContext);
@@ -85,7 +83,21 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Integer_Id_From_QueryString_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: NodeId.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: NodeId.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "B" });
await sut.HandleAsync(authHandlerContext);
Assert.IsFalse(authHandlerContext.HasSucceeded);
AssertContentCached(mockHttpContextAccessor);
}
[Test]
public async Task Node_Integer_Id_From_QueryString_Without_Permission_Is_Not_Authorized_Even_When_Additional_Parameter_For_Id_With_Permission_Is_Provided()
{
// Provides initially failing test and verifies fix for advisory https://github.com/umbraco/Umbraco-CMS/security/advisories/GHSA-wx5h-wqfq-v698
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValues(queryStringValues: new[] { NodeId.ToString(), 1001.ToString() });
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "B" });
await sut.HandleAsync(authHandlerContext);
@@ -98,7 +110,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Udi_Id_From_QueryString_With_Permission_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeUdi.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeUdi.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "A" });
await sut.HandleAsync(authHandlerContext);
@@ -111,7 +123,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Udi_Id_From_QueryString_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeUdi.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeUdi.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "B" });
await sut.HandleAsync(authHandlerContext);
@@ -124,7 +136,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Guid_Id_From_QueryString_With_Permission_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeGuid.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeGuid.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "A" });
await sut.HandleAsync(authHandlerContext);
@@ -137,7 +149,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Guid_Id_From_QueryString_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeGuid.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeGuid.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "B" });
await sut.HandleAsync(authHandlerContext);
@@ -150,7 +162,7 @@ public class ContentPermissionsQueryStringHandlerTests
public async Task Node_Invalid_Id_From_QueryString_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: "invalid");
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: "invalid");
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, new[] { "A" });
await sut.HandleAsync(authHandlerContext);
@@ -169,14 +181,20 @@ public class ContentPermissionsQueryStringHandlerTests
return new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement }, user, resource);
}
private static Mock<IHttpContextAccessor> CreateMockHttpContextAccessor(
private static Mock<IHttpContextAccessor> CreateMockHttpContextAccessorWithQueryStringValue(
string queryStringName = QueryStringName,
string queryStringValue = "")
=> CreateMockHttpContextAccessorWithQueryStringValues(queryStringName, new[] { queryStringValue });
private static Mock<IHttpContextAccessor> CreateMockHttpContextAccessorWithQueryStringValues(
string queryStringName = QueryStringName,
string[]? queryStringValues = null)
{
queryStringValues ??= Array.Empty<string>();
var mockHttpContextAccessor = new Mock<IHttpContextAccessor>();
var mockHttpContext = new Mock<HttpContext>();
var mockHttpRequest = new Mock<HttpRequest>();
var queryParams = new Dictionary<string, StringValues> { { queryStringName, queryStringValue } };
var queryParams = new Dictionary<string, StringValues> { { queryStringName, new StringValues(queryStringValues) } };
mockHttpRequest.SetupGet(x => x.Query).Returns(new QueryCollection(queryParams));
mockHttpContext.SetupGet(x => x.Request).Returns(mockHttpRequest.Object);
mockHttpContext.SetupGet(x => x.Items).Returns(new Dictionary<object, object>());
@@ -2,9 +2,7 @@
// See LICENSE for more details.
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
@@ -34,7 +32,7 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Id_Missing_From_QueryString_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor("xxx");
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue("xxx");
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId);
await sut.HandleAsync(authHandlerContext);
@@ -46,7 +44,7 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Integer_Id_From_QueryString_With_Permission_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: NodeId.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: NodeId.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId);
await sut.HandleAsync(authHandlerContext);
@@ -59,7 +57,21 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Integer_Id_From_QueryString_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: NodeId.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: NodeId.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, 1001);
await sut.HandleAsync(authHandlerContext);
Assert.IsFalse(authHandlerContext.HasSucceeded);
AssertMediaCached(mockHttpContextAccessor);
}
[Test]
public async Task Node_Integer_Id_From_QueryString_Without_Permission_Is_Not_Authorized_Even_When_Additional_Parameter_For_Id_With_Permission_Is_Provided()
{
// Provides initially failing test and verifies fix for advisory https://github.com/umbraco/Umbraco-CMS/security/advisories/GHSA-wx5h-wqfq-v698
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValues(queryStringValues: new[] { NodeId.ToString(), 1001.ToString() });
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, 1001);
await sut.HandleAsync(authHandlerContext);
@@ -72,7 +84,7 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Udi_Id_From_QueryString_With_Permission_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeUdi.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeUdi.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId);
await sut.HandleAsync(authHandlerContext);
@@ -85,7 +97,7 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Udi_Id_From_QueryString_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeUdi.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeUdi.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, 1001);
await sut.HandleAsync(authHandlerContext);
@@ -98,7 +110,7 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Guid_Id_From_QueryString_With_Permission_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeGuid.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeGuid.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId);
await sut.HandleAsync(authHandlerContext);
@@ -111,7 +123,7 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Guid_Id_From_QueryString_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: s_nodeGuid.ToString());
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: s_nodeGuid.ToString());
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId, 1001);
await sut.HandleAsync(authHandlerContext);
@@ -124,7 +136,7 @@ public class MediaPermissionsQueryStringHandlerTests
public async Task Node_Invalid_Id_From_QueryString_Is_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext();
var mockHttpContextAccessor = CreateMockHttpContextAccessor(queryStringValue: "invalid");
var mockHttpContextAccessor = CreateMockHttpContextAccessorWithQueryStringValue(queryStringValue: "invalid");
var sut = CreateHandler(mockHttpContextAccessor.Object, NodeId);
await sut.HandleAsync(authHandlerContext);
@@ -140,14 +152,21 @@ public class MediaPermissionsQueryStringHandlerTests
return new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement }, user, resource);
}
private static Mock<IHttpContextAccessor> CreateMockHttpContextAccessor(
private static Mock<IHttpContextAccessor> CreateMockHttpContextAccessorWithQueryStringValue(
string queryStringName = QueryStringName,
string queryStringValue = "")
=> CreateMockHttpContextAccessorWithQueryStringValues(queryStringName, new[] { queryStringValue });
private static Mock<IHttpContextAccessor> CreateMockHttpContextAccessorWithQueryStringValues(
string queryStringName = QueryStringName,
string[]? queryStringValues = null)
{
queryStringValues ??= Array.Empty<string>();
var mockHttpContextAccessor = new Mock<IHttpContextAccessor>();
var mockHttpContext = new Mock<HttpContext>();
var mockHttpRequest = new Mock<HttpRequest>();
var queryParams = new Dictionary<string, StringValues> { { queryStringName, queryStringValue } };
var queryParams = new Dictionary<string, StringValues> { { queryStringName, new StringValues(queryStringValues) } };
mockHttpRequest.SetupGet(x => x.Query).Returns(new QueryCollection(queryParams));
mockHttpContext.SetupGet(x => x.Request).Returns(mockHttpRequest.Object);
mockHttpContext.SetupGet(x => x.Items).Returns(new Dictionary<object, object>());
@@ -155,6 +174,13 @@ public class MediaPermissionsQueryStringHandlerTests
return mockHttpContextAccessor;
}
/// <summary>
///
/// </summary>
/// <param name="httpContextAccessor"></param>
/// <param name="nodeId"></param>
/// <param name="startMediaId">the startMediaId of the user being setup</param>
/// <returns></returns>
private MediaPermissionsQueryStringHandler CreateHandler(
IHttpContextAccessor httpContextAccessor,
int nodeId,
@@ -179,7 +205,7 @@ public class MediaPermissionsQueryStringHandlerTests
mockEntityService
.Setup(x => x.GetId(
It.Is<Guid>(y => y == s_nodeGuid),
It.Is<UmbracoObjectTypes>(y => y == UmbracoObjectTypes.Document)))
It.Is<UmbracoObjectTypes>(y => y == UmbracoObjectTypes.Media)))
.Returns(Attempt<int>.Succeed(NodeId));
return mockEntityService;
}
@@ -44,7 +44,7 @@ public class MediaPermissionsResourceHandlerTests
}
[Test]
public async Task Resource_With_Node_Id_Withou_Permission_Is_Not_Authorized()
public async Task Resource_With_Node_Id_Without_Permission_Is_Not_Authorized()
{
var authHandlerContext = CreateAuthorizationHandlerContext(NodeId, true);
var sut = CreateHandler(NodeId, 1001);