Compare commits

...
Author SHA1 Message Date
Nhu DinhandGitHub 9505011d71 Build: Updated nightly E2E test pipeline schedule in v16 (#22805)
Updated nightly E2E test pipeline schedule
2026-05-12 15:36:39 +07:00
Nhu DinhandGitHub 2579aaf2db Build: Cherry pick #22164 for V16 (#22171)
Serialize E2E stages and stagger branch schedules to reduce agent usage
2026-03-19 21:19:45 +07:00
Andy Butland 2b7784a226 Merge branch 'release/16.5.1' into v16/dev 2026-03-10 06:36:50 +01:00
8555a97b39 Merge commit from fork
* Add authorization checks for domain operations.

* Remove duplicate 403 ProducesResponseType attributes.

BackOfficeSecurityRequirementsOperationFilterBase already adds 403
responses for endpoints whose controllers inject IAuthorizationService.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-03-10 05:11:16 +01:00
Andy ButlandandGitHub c2dd685a4b Merge commit from fork 2026-03-10 05:10:31 +01:00
Andy ButlandandGitHub 66fc819379 Merge commit from fork
* Protect endpoint that sets user groups for a user collection to prevent elevation of permissions for users.

* Update tests from code review feedback.
2026-03-10 05:07:41 +01:00
Andy Butland 4f1f7e15c4 Bump version to 16.5.1. 2026-02-23 16:38:23 +01:00
Andy Butland a826c52e2e Merge branch 'release/16.5' into v16/dev and bumped version to 16.6.0-rc 2026-01-22 06:45:06 +01:00
Andy Butland 8b2c22aaf1 Merge branch 'release/16.5' of https://github.com/umbraco/Umbraco-CMS into release/16.5 2026-01-21 17:40:17 +01:00
Andy Butland aecfee4469 Bump version to 16.5.0. 2026-01-21 17:39:59 +01:00
Niels LyngsøandGitHub 9c785a9c5b Varying Compositions in Invariant Document Types, Cherrypick of #21267 (#21472)
* cherry picked a5a6d0645f

* correct to getVariesBySegment
2026-01-21 15:27:26 +00:00
Nhu DinhandGitHub 2fe10387ee E2E: V16 QA Update acceptance tests to use refactored UI helpers (#21271)
* Bumped version of test helper

* Bumped version

* Bumped version
2026-01-05 17:25:23 +07:00
18 changed files with 724 additions and 58 deletions
+6 -6
View File
@@ -4,13 +4,11 @@ pr: none
trigger: none
schedules:
- cron: '0 0 * * *'
displayName: Daily midnight build
- cron: '0 6 * * *'
displayName: Daily 6 AM build (v16/dev)
branches:
include:
- v15/dev
- v16/dev
- main
parameters:
- name: skipIntegrationTests
@@ -295,7 +293,8 @@ stages:
- stage: DefaultConfigE2E
displayName: Default Config E2E Tests
dependsOn: Build
dependsOn: Integration
condition: always()
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
# Enable console logging in Release mode
@@ -476,7 +475,8 @@ stages:
- stage: AdditionalConfigE2E
displayName: Additional Config E2E Tests
dependsOn: Build
dependsOn: DefaultConfigE2E
condition: always()
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
ASPNETCORE_URLS: https://localhost:44331
@@ -1,31 +1,60 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
[ApiVersion("1.0")]
public class DomainsController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IDomainService _domainService;
private readonly IUmbracoMapper _umbracoMapper;
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
[ActivatorUtilitiesConstructor]
public DomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper)
{
_authorizationService = authorizationService;
_domainService = domainService;
_umbracoMapper = umbracoMapper;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
domainService,
umbracoMapper)
{
}
[MapToApiVersion("1.0")]
[HttpGet("{id:guid}/domains")]
[ProducesResponseType(typeof(DomainsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Domains(CancellationToken cancellationToken, Guid id)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
IDomain[] assignedDomains = (await _domainService.GetAssignedDomainsAsync(id, true))
.OrderBy(d => d.SortOrder)
.ToArray();
@@ -1,13 +1,19 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
@@ -15,17 +21,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document;
[ApiVersion("1.0")]
public class UpdateDomainsController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IDomainService _domainService;
private readonly IUmbracoMapper _umbracoMapper;
private readonly IDomainPresentationFactory _domainPresentationFactory;
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
[ActivatorUtilitiesConstructor]
public UpdateDomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
{
_authorizationService = authorizationService;
_domainService = domainService;
_umbracoMapper = umbracoMapper;
_domainPresentationFactory = domainPresentationFactory;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
domainService,
umbracoMapper,
domainPresentationFactory)
{
}
[MapToApiVersion("1.0")]
[HttpPut("{id:guid}/domains")]
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -37,6 +56,16 @@ public class UpdateDomainsController : DocumentControllerBase
Guid id,
UpdateDomainsRequestModel updateModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionAssignDomain.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
DomainsUpdateModel domainsUpdateModel = _umbracoMapper.Map<DomainsUpdateModel>(updateModel)!;
Attempt<DomainUpdateResult, DomainOperationStatus> result = await _domainService.UpdateDomainsAsync(id, domainsUpdateModel);
@@ -1,33 +1,63 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
[ApiVersion("1.0")]
public class UpdateNotificationsController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly INotificationService _notificationService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
[ActivatorUtilitiesConstructor]
public UpdateNotificationsController(IAuthorizationService authorizationService, IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_notificationService = notificationService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
contentEditingService,
notificationService,
backOfficeSecurityAccessor)
{
}
[MapToApiVersion("1.0")]
[HttpPut("{id:guid}/notifications")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateNotifications(CancellationToken cancellationToken, Guid id, UpdateDocumentNotificationsRequestModel updateModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
IContent? content = await _contentEditingService.GetAsync(id);
if (content == null)
{
@@ -1,11 +1,14 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Controllers.UserGroup;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.User;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -25,11 +28,26 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IUserGroupService _userGroupService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
[ActivatorUtilitiesConstructor]
public UpdateUserGroupsUserController(
IAuthorizationService authorizationService,
IUserGroupService userGroupService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_userGroupService = userGroupService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[Obsolete("Please use the constructor accepting all parameters. Scheduled for removal in Umbraco 19.")]
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
: this(
authorizationService,
userGroupService,
StaticServiceProvider.Instance.GetRequiredService<IBackOfficeSecurityAccessor>())
{
}
[HttpPost("set-user-groups")]
@@ -51,7 +69,8 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
Attempt<UserGroupOperationStatus> result = await _userGroupService.UpdateUserGroupsOnUsersAsync(
requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
requestModel.UserIds.Select(x => x.Id).ToHashSet());
requestModel.UserIds.Select(x => x.Id).ToHashSet(),
CurrentUserKey(_backOfficeSecurityAccessor));
return result.Success
? Ok()
@@ -81,46 +81,23 @@ public class UserEditorAuthorizationHelper
return Attempt<string?>.Succeed();
}
// d) a non-admin user can remove any groups but can only add groups they themselves belong to
if (userGroupAliases != null)
{
var savingGroupAliases = userGroupAliases.ToArray();
var existingGroupAliases = savingUser == null
IEnumerable<string> requestedGroupAliases = userGroupAliases.ToArray();
IEnumerable<string> existingGroupAliases = savingUser == null
? []
: savingUser.Groups.Select(x => x.Alias).ToArray();
: savingUser.Groups.Select(x => x.Alias);
IEnumerable<string> performingUserGroupAliases = currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>();
IEnumerable<string> addedGroupAliases = savingGroupAliases.Except(existingGroupAliases);
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
// As we know the current user is not admin, it is only allowed to use groups that the user do have themselves.
var savingGroupAliasesNotAllowed = addedGroupAliases
.Except(currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>()).ToArray();
if (savingGroupAliasesNotAllowed.Any())
if (unauthorized.Count > 0)
{
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", savingGroupAliasesNotAllowed) +
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", unauthorized) +
"', the current user is not part of them or admin");
}
// only validate any groups that have changed.
// a non-admin user can remove groups and add groups that they have access to
// but they cannot add a group that they do not have access to or that grants them
// path or section access that they don't have access to.
var newGroups = savingUser == null
? savingGroupAliases
: savingGroupAliases.Except(savingUser.Groups.Select(x => x.Alias)).ToArray();
var userGroupsChanged = savingUser != null && newGroups.Length > 0;
if (userGroupsChanged)
{
// d) A user cannot assign a group to another user that they do not belong to
var currentUserGroups = currentUser?.Groups.Select(x => x.Alias).ToArray();
foreach (var group in newGroups)
{
if (currentUserGroups?.Contains(group) == false)
{
return Attempt.Fail("Cannot assign the group " + group + ", the current user is not a member");
}
}
}
}
return Attempt<string?>.Succeed();
@@ -0,0 +1,34 @@
namespace Umbraco.Cms.Core.Security;
/// <summary>
/// Shared authorization logic for user group assignment.
/// </summary>
public static class UserGroupAssignmentAuthorization
{
/// <summary>
/// Returns the group aliases that the performing user is not authorized to assign.
/// </summary>
/// <param name="performingUserGroupAliases">The group aliases the performing user belongs to.</param>
/// <param name="requestedGroupAliases">The group aliases being assigned to the target user.</param>
/// <param name="existingGroupAliases">The group aliases the target user currently belongs to.</param>
/// <returns>
/// Group aliases that are being added but the performing user does not belong to.
/// An empty collection means the assignment is authorized.
/// </returns>
/// <remarks>
/// Non-admin users can remove any groups but can only add groups they themselves belong to.
/// Callers should check for admin status before calling this method, as admins bypass this check.
/// </remarks>
public static IReadOnlyList<string> GetUnauthorizedGroupAssignments(
IEnumerable<string> performingUserGroupAliases,
IEnumerable<string> requestedGroupAliases,
IEnumerable<string> existingGroupAliases)
{
var performingGroups = performingUserGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
var existingGroups = existingGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
return requestedGroupAliases
.Where(alias => existingGroups.Contains(alias) is false && performingGroups.Contains(alias) is false)
.ToArray();
}
}
+18 -1
View File
@@ -1,4 +1,4 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -107,8 +107,25 @@ public interface IUserGroupService
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
/// <param name="userKeys">The user whose groups we want to alter.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
[Obsolete("Please use the overload accepting all parameters. Scheduled for removal in Umbraco 19.")]
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys);
/// <summary>
/// Updates the users to have the groups specified, with authorization checks based on the performing user.
/// </summary>
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
/// <param name="userKeys">The user whose groups we want to alter.</param>
/// <param name="performingUserKey">The key of the user performing the operation.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
/// <remarks>
/// Non-admin users can only add groups they themselves belong to. Removing groups is always allowed.
/// </remarks>
// TODO (V18): Remove default implementation.
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys, Guid performingUserKey)
#pragma warning disable CS0618 // Type or member is obsolete
=> UpdateUserGroupsOnUsersAsync(userGroupKeys, userKeys);
#pragma warning restore CS0618 // Type or member is obsolete
Task<Attempt<UserGroupOperationStatus>> AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey);
Task<Attempt<UserGroupOperationStatus>> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey);
}
+51 -1
View File
@@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Persistence;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.AuthorizationStatus;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
@@ -210,9 +211,24 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
public async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
// TODO (V19): Collapse the following three methods into a single one, once the obsolete overload
// of UpdateUserGroupsOnUsersAsync is removed from the interface.
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
ISet<Guid> userGroupKeys,
ISet<Guid> userKeys)
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey: null);
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
ISet<Guid> userGroupKeys,
ISet<Guid> userKeys,
Guid performingUserKey)
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey);
private async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersInternalAsync(
ISet<Guid> userGroupKeys,
ISet<Guid> userKeys,
Guid? performingUserKey)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
@@ -222,6 +238,40 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
.Select(x => x.ToReadOnlyGroup())
.ToArray();
// Authorize the performing user if provided.
if (performingUserKey.HasValue)
{
IUser? performingUser = await _userService.GetAsync(performingUserKey.Value);
if (performingUser is null)
{
scope.Complete();
return Attempt.Fail(UserGroupOperationStatus.MissingUser);
}
if (performingUser.IsAdmin() is false)
{
string[] performingUserGroupAliases = performingUser.Groups.Select(g => g.Alias).ToArray();
string[] requestedGroupAliases = userGroups.Select(g => g.Alias).ToArray();
foreach (IUser user in users)
{
IEnumerable<string> existingGroupAliases = user.Groups.Select(g => g.Alias);
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
if (unauthorized.Count > 0)
{
_logger.LogInformation(
"The performing user is not allowed to assign user group(s) '{GroupAliases}' because they do not belong to them.",
string.Join(", ", unauthorized));
scope.Complete();
return Attempt.Fail(UserGroupOperationStatus.Unauthorized);
}
}
}
}
// This means that we're potentially de-admining a user, which might cause the admin group to be empty.
if (userGroupKeys.Contains(Constants.Security.AdminGroupKey) is false)
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@umbraco-cms/backoffice",
"license": "MIT",
"version": "16.5.0-rc",
"version": "16.6.0-rc",
"type": "module",
"exports": {
".": null,
@@ -420,6 +420,8 @@ export abstract class UmbContentDetailWorkspaceContextBase<
const repo = new UmbDataTypeDetailRepository(this);
const propertyTypes = await this.structure.getContentTypeProperties();
const contentTypeVariesByCulture = this.structure.getVariesByCulture();
const contentTypeVariesBySegment = this.structure.getVariesBySegment();
const valueDefinitions = await Promise.all(
propertyTypes.map(async (property) => {
// TODO: Implement caching for data-type requests. [NL]
@@ -438,8 +440,9 @@ export abstract class UmbContentDetailWorkspaceContextBase<
propertyEditorSchemaAlias: dataType.editorAlias,
config: dataType.values,
typeArgs: {
variesByCulture: property.variesByCulture,
variesBySegment: property.variesBySegment,
// Only vary if the content type varies:
variesByCulture: contentTypeVariesByCulture ? property.variesByCulture : false,
variesBySegment: contentTypeVariesBySegment ? property.variesBySegment : false,
} as UmbPropertyTypePresetModelTypeModel,
} as UmbPropertyTypePresetModel;
}),
@@ -0,0 +1,86 @@
import { expect } from '@open-wc/testing';
import { UmbMarked } from './ufm.context.js';
describe('UmbMarked sanitization', () => {
describe('XSS prevention on custom elements', () => {
it('should strip onclick from custom elements', async () => {
const markup = await UmbMarked.parseInline('<uui-button onclick="alert(1)">Click</uui-button>');
expect(markup).to.not.include('onclick');
expect(markup).to.include('<uui-button>');
});
it('should strip onload from custom elements', async () => {
const markup = await UmbMarked.parseInline('<umb-test onload="alert(1)">Test</umb-test>');
expect(markup).to.not.include('onload');
expect(markup).to.include('<umb-test>');
});
it('should strip onmouseover from custom elements', async () => {
const markup = await UmbMarked.parseInline('<uui-box onmouseover="alert(1)">Hover</uui-box>');
expect(markup).to.not.include('onmouseover');
expect(markup).to.include('<uui-box>');
});
it('should strip onfocus from custom elements', async () => {
const markup = await UmbMarked.parseInline('<ufm-label-value onfocus="alert(1)" alias="test"></ufm-label-value>');
expect(markup).to.not.include('onfocus');
expect(markup).to.include('alias="test"');
});
it('should strip onerror from custom elements', async () => {
const markup = await UmbMarked.parseInline('<umb-test onerror="alert(1)">Test</umb-test>');
expect(markup).to.not.include('onerror');
});
});
describe('safe attributes on custom elements', () => {
it('should preserve class attribute', async () => {
const markup = await UmbMarked.parseInline('<uui-button class="primary">Click</uui-button>');
expect(markup).to.include('class="primary"');
});
it('should preserve alias attribute', async () => {
const markup = await UmbMarked.parseInline('<ufm-label-value alias="prop1"></ufm-label-value>');
expect(markup).to.include('alias="prop1"');
});
it('should preserve look attribute', async () => {
const markup = await UmbMarked.parseInline('<uui-button look="primary">Click</uui-button>');
expect(markup).to.include('look="primary"');
});
it('should preserve slot attribute', async () => {
const markup = await UmbMarked.parseInline('<umb-test slot="header">Test</umb-test>');
expect(markup).to.include('slot="header"');
});
it('should preserve label attribute', async () => {
const markup = await UmbMarked.parseInline('<uui-button label="Submit">Click</uui-button>');
expect(markup).to.include('label="Submit"');
});
});
describe('standard HTML sanitization', () => {
it('should strip script tags', async () => {
const markup = await UmbMarked.parseInline('<script>alert(1)</script>');
expect(markup).to.not.include('<script');
});
it('should allow standard markdown bold', async () => {
const markup = await UmbMarked.parseInline('**bold text**');
expect(markup).to.include('<strong>bold text</strong>');
});
it('should allow standard markdown links', async () => {
const markup = await UmbMarked.parseInline('[link](https://example.com)');
expect(markup).to.include('<a');
expect(markup).to.include('href="https://example.com"');
});
it('should strip non-allowed custom elements', async () => {
const markup = await UmbMarked.parseInline('<custom-evil onclick="alert(1)">Test</custom-evil>');
expect(markup).to.not.include('onclick');
expect(markup).to.not.include('<custom-evil');
});
});
});
@@ -15,7 +15,7 @@ const UmbDomPurifyConfig: DOMPurifyConfig = {
USE_PROFILES: { html: true },
CUSTOM_ELEMENT_HANDLING: {
tagNameCheck: /^(?:ufm|umb|uui)-.*$/,
attributeNameCheck: /.+/,
attributeNameCheck: /^(?!on)/,
allowCustomizedBuiltInElements: false,
},
};
+4 -4
View File
@@ -8,7 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@umbraco/json-models-builders": "^2.0.40",
"@umbraco/playwright-testhelpers": "^16.0.58",
"@umbraco/playwright-testhelpers": "^16.0.60",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"
@@ -67,9 +67,9 @@
}
},
"node_modules/@umbraco/playwright-testhelpers": {
"version": "16.0.58",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.58.tgz",
"integrity": "sha512-8NWupbb526Ni3eDF/0dVGDSuIYGPP+4lGxtcpTtA0/0p3Ud/9vTYym/k+KgBElDdlY278yXaWez0IpKhShdLOw==",
"version": "16.0.60",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.60.tgz",
"integrity": "sha512-/6CS4YtsNN3vtahaG35xj5BNYlJMcIPKdiUBrmilWCYAFzxIT9u6EegWBtua46bQxcdeqQgUvIAghsNvDPEk1A==",
"license": "MIT",
"dependencies": {
"@umbraco/json-models-builders": "2.0.40",
@@ -22,7 +22,7 @@
},
"dependencies": {
"@umbraco/json-models-builders": "^2.0.40",
"@umbraco/playwright-testhelpers": "^16.0.58",
"@umbraco/playwright-testhelpers": "^16.0.60",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"
@@ -0,0 +1,122 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Security;
[TestFixture]
public class UserGroupAssignmentAuthorizationTests
{
[Test]
public void Returns_Empty_When_No_Groups_Are_Being_Added()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["editor"],
requestedGroupAliases: ["editor"],
existingGroupAliases: ["editor"]);
Assert.IsEmpty(result);
}
[Test]
public void Returns_Empty_When_Added_Groups_Belong_To_Performer()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["editor", "writer"],
requestedGroupAliases: ["editor", "writer"],
existingGroupAliases: ["editor"]);
Assert.IsEmpty(result);
}
[Test]
public void Returns_Unauthorized_Groups_Not_Belonging_To_Performer()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["editor"],
requestedGroupAliases: ["editor", "admin"],
existingGroupAliases: ["editor"]);
Assert.AreEqual(1, result.Count);
Assert.AreEqual("admin", result[0]);
}
[Test]
public void Existing_Groups_Are_Not_Flagged_Even_If_Performer_Does_Not_Belong()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["editor"],
requestedGroupAliases: ["writer", "editor"],
existingGroupAliases: ["writer"]);
Assert.IsEmpty(result);
}
[Test]
public void Returns_All_Unauthorized_When_Multiple_New_Groups_Fail()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["editor"],
requestedGroupAliases: ["editor", "admin", "sensitiveData"],
existingGroupAliases: []);
Assert.AreEqual(2, result.Count);
CollectionAssert.AreEquivalent(new[] { "admin", "sensitiveData" }, result);
}
[Test]
public void Returns_Empty_When_All_Requested_Groups_Are_Removals()
{
// Requesting fewer groups than existing = only removals, no additions
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["editor"],
requestedGroupAliases: ["editor"],
existingGroupAliases: ["editor", "writer", "admin"]);
Assert.IsEmpty(result);
}
[Test]
public void Comparison_Is_Case_Insensitive()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["Editor"],
requestedGroupAliases: ["editor", "Writer"],
existingGroupAliases: ["WRITER"]);
Assert.IsEmpty(result);
}
[Test]
public void Returns_Empty_When_No_Groups_Requested()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: ["editor"],
requestedGroupAliases: [],
existingGroupAliases: ["editor", "writer"]);
Assert.IsEmpty(result);
}
[Test]
public void Returns_Empty_When_Performer_Has_No_Groups_But_Nothing_Added()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: [],
requestedGroupAliases: ["editor"],
existingGroupAliases: ["editor"]);
Assert.IsEmpty(result);
}
[Test]
public void Returns_Unauthorized_When_Performer_Has_No_Groups_And_Groups_Added()
{
var result = UserGroupAssignmentAuthorization.GetUnauthorizedGroupAssignments(
performingUserGroupAliases: [],
requestedGroupAliases: ["editor"],
existingGroupAliases: []);
Assert.AreEqual(1, result.Count);
Assert.AreEqual("editor", result[0]);
}
}
@@ -169,6 +169,276 @@ public class UserGroupServiceTests
Assert.AreEqual(status, updateAttempt.Status);
}
[Test]
public async Task UpdateUserGroupsOnUsers_Admin_Can_Assign_Any_Groups()
{
// Arrange - admin performing user assigning a group they don't belong to (sensitive)
// This verifies the admin bypass: without it, assigning "sensitive" would be unauthorized.
var performingUserKey = Guid.NewGuid();
var targetUserKey = Guid.NewGuid();
var sensitiveGroupKey = Constants.Security.SensitiveDataGroupKey;
var adminGroupKey = Constants.Security.AdminGroupKey;
var service = SetupUserGroupServiceForUpdateUserGroups(
performingUserKey,
performingUserGroupAliases: [Constants.Security.AdminGroupAlias],
targetUserKey,
targetUserCurrentGroupAliases: ["editor"],
requestedGroups: [(adminGroupKey, Constants.Security.AdminGroupAlias), (sensitiveGroupKey, "sensitive")]);
// Act
var result = await service.UpdateUserGroupsOnUsersAsync(
new HashSet<Guid> { adminGroupKey, sensitiveGroupKey },
new HashSet<Guid> { targetUserKey },
performingUserKey);
// Assert
Assert.IsTrue(result.Success);
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
}
[Test]
public async Task UpdateUserGroupsOnUsers_NonAdmin_Can_Add_Groups_They_Belong_To()
{
// Arrange - Editor performing user adding Editor group to a Writer user
var performingUserKey = Guid.NewGuid();
var targetUserKey = Guid.NewGuid();
var writerGroupKey = Guid.NewGuid();
var editorGroupKey = Guid.NewGuid();
var service = SetupUserGroupServiceForUpdateUserGroups(
performingUserKey,
performingUserGroupAliases: ["editor"],
targetUserKey,
targetUserCurrentGroupAliases: ["writer"],
requestedGroups: [(writerGroupKey, "writer"), (editorGroupKey, "editor")]);
// Act
var result = await service.UpdateUserGroupsOnUsersAsync(
new HashSet<Guid> { writerGroupKey, editorGroupKey },
new HashSet<Guid> { targetUserKey },
performingUserKey);
// Assert
Assert.IsTrue(result.Success);
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
}
[Test]
public async Task UpdateUserGroupsOnUsers_NonAdmin_Cannot_Add_Groups_They_Do_Not_Belong_To()
{
// Arrange - Editor performing user trying to add Admin group to an Editor user
var performingUserKey = Guid.NewGuid();
var targetUserKey = Guid.NewGuid();
var editorGroupKey = Guid.NewGuid();
var adminGroupKey = Constants.Security.AdminGroupKey;
var service = SetupUserGroupServiceForUpdateUserGroups(
performingUserKey,
performingUserGroupAliases: ["editor"],
targetUserKey,
targetUserCurrentGroupAliases: ["editor"],
requestedGroups: [(editorGroupKey, "editor"), (adminGroupKey, Constants.Security.AdminGroupAlias)]);
// Act
var result = await service.UpdateUserGroupsOnUsersAsync(
new HashSet<Guid> { editorGroupKey, adminGroupKey },
new HashSet<Guid> { targetUserKey },
performingUserKey);
// Assert
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.Unauthorized, result.Result);
}
[Test]
public async Task UpdateUserGroupsOnUsers_NonAdmin_Can_Keep_Existing_Groups_They_Do_Not_Belong_To()
{
// Arrange - Editor user keeping Writer+Translator (existing) and adding Editor (own group)
var performingUserKey = Guid.NewGuid();
var targetUserKey = Guid.NewGuid();
var writerGroupKey = Guid.NewGuid();
var translatorGroupKey = Guid.NewGuid();
var editorGroupKey = Guid.NewGuid();
var service = SetupUserGroupServiceForUpdateUserGroups(
performingUserKey,
performingUserGroupAliases: ["editor"],
targetUserKey,
targetUserCurrentGroupAliases: ["writer", "translator"],
requestedGroups: [(writerGroupKey, "writer"), (translatorGroupKey, "translator"), (editorGroupKey, "editor")]);
// Act
var result = await service.UpdateUserGroupsOnUsersAsync(
new HashSet<Guid> { writerGroupKey, translatorGroupKey, editorGroupKey },
new HashSet<Guid> { targetUserKey },
performingUserKey);
// Assert
Assert.IsTrue(result.Success);
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
}
[Test]
public async Task UpdateUserGroupsOnUsers_NonAdmin_Can_Remove_Groups()
{
// Arrange - Editor user removing Writer group from a user that has both Writer and Editor
var performingUserKey = Guid.NewGuid();
var targetUserKey = Guid.NewGuid();
var editorGroupKey = Guid.NewGuid();
var service = SetupUserGroupServiceForUpdateUserGroups(
performingUserKey,
performingUserGroupAliases: ["editor"],
targetUserKey,
targetUserCurrentGroupAliases: ["writer", "editor"],
requestedGroups: [(editorGroupKey, "editor")]);
// Act
var result = await service.UpdateUserGroupsOnUsersAsync(
new HashSet<Guid> { editorGroupKey },
new HashSet<Guid> { targetUserKey },
performingUserKey);
// Assert
Assert.IsTrue(result.Success);
Assert.AreEqual(UserGroupOperationStatus.Success, result.Result);
}
[Test]
public async Task UpdateUserGroupsOnUsers_NonAdmin_Cannot_Escalate_Via_Replacement()
{
// Arrange - Editor user replacing their Editor group with Admin group
var performingUserKey = Guid.NewGuid();
var targetUserKey = Guid.NewGuid();
var adminGroupKey = Constants.Security.AdminGroupKey;
var service = SetupUserGroupServiceForUpdateUserGroups(
performingUserKey,
performingUserGroupAliases: ["editor"],
targetUserKey,
targetUserCurrentGroupAliases: ["editor"],
requestedGroups: [(adminGroupKey, Constants.Security.AdminGroupAlias)]);
// Act
var result = await service.UpdateUserGroupsOnUsersAsync(
new HashSet<Guid> { adminGroupKey },
new HashSet<Guid> { targetUserKey },
performingUserKey);
// Assert
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.Unauthorized, result.Result);
}
[Test]
public async Task UpdateUserGroupsOnUsers_Missing_Performing_User_Returns_MissingUser()
{
// Arrange - performing user key that doesn't resolve to a user
var performingUserKey = Guid.NewGuid();
var targetUserKey = Guid.NewGuid();
var editorGroupKey = Guid.NewGuid();
var scope = new Mock<ICoreScope>();
var provider = new Mock<ICoreScopeProvider>();
provider.Setup(p => p.CreateCoreScope(
It.IsAny<IsolationLevel>(),
It.IsAny<RepositoryCacheMode>(),
It.IsAny<IEventDispatcher?>(),
It.IsAny<IScopedNotificationPublisher?>(),
It.IsAny<bool?>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(scope.Object);
var query = new Mock<IQuery<IUserGroup>>();
query.Setup(q => q.Where(It.IsAny<Expression<Func<IUserGroup, bool>>>())).Returns(query.Object);
provider.Setup(p => p.CreateQuery<IUserGroup>()).Returns(query.Object);
var targetUser = SetupUserWithGroupAccess(targetUserKey, ["editor"]);
var userService = new Mock<IUserService>();
// Performing user not found
userService.Setup(s => s.GetAsync(performingUserKey)).Returns(Task.FromResult<IUser?>(null));
userService.Setup(s => s.GetAsync(It.IsAny<IEnumerable<Guid>>()))
.Returns(Task.FromResult<IEnumerable<IUser>>(new[] { targetUser.Object }));
var userGroupRepository = new Mock<IUserGroupRepository>();
userGroupRepository
.Setup(r => r.Get(It.IsAny<IQuery<IUserGroup>>()))
.Returns(new[] { new UserGroup(Mock.Of<IShortStringHelper>(), 0, "editor", "Editor", null) { Key = editorGroupKey } });
var service = new UserGroupService(
provider.Object,
Mock.Of<ILoggerFactory>(),
Mock.Of<IEventMessagesFactory>(),
userGroupRepository.Object,
Mock.Of<IUserGroupPermissionService>(),
Mock.Of<IEntityService>(),
userService.Object,
Mock.Of<ILogger<UserGroupService>>());
// Act
var result = await service.UpdateUserGroupsOnUsersAsync(
new HashSet<Guid> { editorGroupKey },
new HashSet<Guid> { targetUserKey },
performingUserKey);
// Assert
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.MissingUser, result.Result);
}
private UserGroupService SetupUserGroupServiceForUpdateUserGroups(
Guid performingUserKey,
string[] performingUserGroupAliases,
Guid targetUserKey,
string[] targetUserCurrentGroupAliases,
(Guid Key, string Alias)[] requestedGroups)
{
var scope = new Mock<ICoreScope>();
var provider = new Mock<ICoreScopeProvider>();
provider.Setup(p => p.CreateCoreScope(
It.IsAny<IsolationLevel>(),
It.IsAny<RepositoryCacheMode>(),
It.IsAny<IEventDispatcher?>(),
It.IsAny<IScopedNotificationPublisher?>(),
It.IsAny<bool?>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(scope.Object);
var query = new Mock<IQuery<IUserGroup>>();
query.Setup(q => q.Where(It.IsAny<Expression<Func<IUserGroup, bool>>>())).Returns(query.Object);
provider.Setup(p => p.CreateQuery<IUserGroup>()).Returns(query.Object);
var performingUser = SetupUserWithGroupAccess(performingUserKey, performingUserGroupAliases);
var targetUser = SetupUserWithGroupAccess(targetUserKey, targetUserCurrentGroupAliases);
var userService = new Mock<IUserService>();
userService.Setup(s => s.GetAsync(performingUserKey)).Returns(Task.FromResult<IUser?>(performingUser.Object));
userService.Setup(s => s.GetAsync(It.IsAny<IEnumerable<Guid>>()))
.Returns(Task.FromResult<IEnumerable<IUser>>(new[] { targetUser.Object }));
IUserGroup[] userGroups = requestedGroups
.Select(g => new UserGroup(Mock.Of<IShortStringHelper>(), 0, g.Alias, g.Alias, null) { Key = g.Key })
.ToArray<IUserGroup>();
var userGroupRepository = new Mock<IUserGroupRepository>();
userGroupRepository
.Setup(r => r.Get(It.IsAny<IQuery<IUserGroup>>()))
.Returns(userGroups);
return new UserGroupService(
provider.Object,
Mock.Of<ILoggerFactory>(),
Mock.Of<IEventMessagesFactory>(),
userGroupRepository.Object,
Mock.Of<IUserGroupPermissionService>(),
Mock.Of<IEntityService>(),
userService.Object,
Mock.Of<ILogger<UserGroupService>>());
}
private IEnumerable<IReadOnlyUserGroup> CreateGroups(params string[] aliases)
=> aliases.Select(alias =>
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "16.5.0-rc",
"version": "16.6.0-rc",
"assemblyVersion": {
"precision": "build"
},