-
Notifications
You must be signed in to change notification settings - Fork 1
Fixing admin entity authorization bypass via client-controlled sectionCrumbs #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop-7.0.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,7 +31,6 @@ | |
| import org.broadleafcommerce.openadmin.dto.Entity; | ||
| import org.broadleafcommerce.openadmin.dto.PersistencePackage; | ||
| import org.broadleafcommerce.openadmin.dto.Property; | ||
| import org.broadleafcommerce.openadmin.dto.SectionCrumb; | ||
| import org.broadleafcommerce.openadmin.server.security.domain.AdminPermission; | ||
| import org.broadleafcommerce.openadmin.server.security.domain.AdminRole; | ||
| import org.broadleafcommerce.openadmin.server.security.domain.AdminUser; | ||
|
|
@@ -40,17 +39,13 @@ | |
| import org.broadleafcommerce.openadmin.server.security.service.type.PermissionType; | ||
| import org.broadleafcommerce.openadmin.server.service.ValidationException; | ||
| import org.broadleafcommerce.openadmin.server.service.persistence.validation.GlobalValidationResult; | ||
| import org.springframework.cglib.core.CollectionUtils; | ||
| import org.springframework.cglib.core.Transformer; | ||
| import org.springframework.security.core.Authentication; | ||
| import org.springframework.security.core.context.SecurityContext; | ||
| import org.springframework.security.core.context.SecurityContextHolder; | ||
| import org.springframework.security.core.userdetails.UserDetails; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import java.util.Arrays; | ||
| import java.util.HashSet; | ||
| import java.util.Set; | ||
|
|
||
| import jakarta.annotation.Resource; | ||
|
|
||
|
|
@@ -133,17 +128,10 @@ public AdminUser getPersistentAdminUser() { | |
|
|
||
| @Override | ||
| public void securityCheck(PersistencePackage persistencePackage, EntityOperationType operationType) throws ServiceException { | ||
| Set<String> ceilingNames = new HashSet<>(); | ||
| ceilingNames.add(persistencePackage.getSecurityCeilingEntityFullyQualifiedClassname()); | ||
| if (!ArrayUtils.isEmpty(persistencePackage.getSectionCrumbs())) { | ||
| ceilingNames.addAll(CollectionUtils.transform(Arrays.asList(persistencePackage.getSectionCrumbs()), | ||
| new Transformer() { | ||
| @Override | ||
| public Object transform(Object o) { | ||
| return ((SectionCrumb) o).getSectionIdentifier(); | ||
| } | ||
| })); | ||
| } | ||
| //Authorization is performed exclusively against the security ceiling of the entity actually being operated | ||
| //on. Section crumbs are client supplied navigation state and must never widen the set of ceilings the user | ||
| //is authorized against. | ||
| String securityCeiling = persistencePackage.getSecurityCeilingEntityFullyQualifiedClassname(); | ||
|
|
||
| Entity entity = persistencePackage.getEntity(); | ||
|
|
||
|
|
@@ -186,14 +174,18 @@ public Object transform(Object o) { | |
| } | ||
| } | ||
|
|
||
| securityCheck(ceilingNames.toArray(new String[ceilingNames.size()]), operationType); | ||
| securityCheck(securityCeiling, operationType); | ||
| } | ||
|
|
||
| @Override | ||
| public void securityCheck(String ceilingEntityFullyQualifiedName, EntityOperationType operationType) throws ServiceException { | ||
| securityCheck(new String[]{ceilingEntityFullyQualifiedName}, operationType); | ||
| } | ||
|
|
||
| /** | ||
| * Verifies the current admin user is qualified for the given operation on <b>every</b> supplied ceiling. A user | ||
| * must never gain access to one ceiling by virtue of holding a permission on another. | ||
| */ | ||
| protected void securityCheck(String[] ceilingNames, EntityOperationType operationType) throws ServiceException { | ||
|
Comment on lines
+185
to
189
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Multi-ceiling helper semantics flipped from OR to AND with no remaining multi-ceiling caller After this change the only production caller reaches Was this helpful? React with 👍 or 👎 to provide feedback. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentionally left |
||
| if (ArrayUtils.isEmpty(ceilingNames)) { | ||
| throw new SecurityServiceException("Security Check Failed: ceilingNames not specified"); | ||
|
|
@@ -228,25 +220,23 @@ protected void securityCheck(String[] ceilingNames, EntityOperationType operatio | |
| } | ||
|
|
||
| SecurityServiceException primaryException = null; | ||
| boolean isQualified = false; | ||
| String unqualifiedCeiling = null; | ||
| for (String ceilingEntityFullyQualifiedName : ceilingNames) { | ||
| isQualified = securityService.isUserQualifiedForOperationOnCeilingEntity( | ||
| boolean isQualified = securityService.isUserQualifiedForOperationOnCeilingEntity( | ||
| persistentAdminUser, permissionType, ceilingEntityFullyQualifiedName | ||
| ); | ||
| if (!isQualified) { | ||
| if (primaryException == null) { | ||
| primaryException = new SecurityServiceException("Security Check Failed for entity operation: " | ||
| + operationType.toString() + " (" + ceilingEntityFullyQualifiedName + ")"); | ||
| } | ||
| } else { | ||
| unqualifiedCeiling = ceilingEntityFullyQualifiedName; | ||
| primaryException = new SecurityServiceException("Security Check Failed for entity operation: " | ||
| + operationType.toString() + " (" + ceilingEntityFullyQualifiedName + ")"); | ||
| break; | ||
| } | ||
| } | ||
| if (!isQualified) { | ||
| if (primaryException != null) { | ||
| //check if the requested entity is not configured and warn | ||
| if (!securityService.doesOperationExistForCeilingEntity(permissionType, ceilingNames[0])) { | ||
| if (!securityService.doesOperationExistForCeilingEntity(permissionType, unqualifiedCeiling)) { | ||
| if (LOG.isWarnEnabled()) { | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| LOG.warn("Detected security request for an unregistered ceiling entity (" + StringUtil.sanitize(ceilingNames[0]) + "). " + | ||
| LOG.warn("Detected security request for an unregistered ceiling entity (" + StringUtil.sanitize(unqualifiedCeiling) + "). " + | ||
| "As a result, the request failed. Please make sure to configure security for any ceiling entities " + | ||
| "referenced via the admin. This is usually accomplished by adding records in the " + | ||
| "BLC_ADMIN_PERMISSION_ENTITY table. Note, depending on how the entity in question is used, you " + | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -652,6 +652,10 @@ public PersistenceResponse addSubCollectionEntity( | |
|
|
||
| if (fmd.getAddMethodType().equals(AddMethodType.LOOKUP_FOR_UPDATE)) { | ||
| ppr.setUpdateLookupType(true); | ||
| //the operation updates the looked up member of a collection owned by the entity currently being | ||
| //managed, so authorize against that owning entity's ceiling, as derived from server side metadata | ||
| ppr.withSecurityCeilingEntityClassname(StringUtils.isNotBlank(mainMetadata.getSecurityCeilingType()) | ||
| ? mainMetadata.getSecurityCeilingType() : mainMetadata.getCeilingType()); | ||
|
Comment on lines
+655
to
+658
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: LOOKUP_FOR_UPDATE still authorizes only against the owning entity, not the entity being modified For Was this helpful? React with 👍 or 👎 to provide feedback. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as the other LOOKUP_FOR_UPDATE thread — noted as latent and left unchanged deliberately; see #97 (comment).
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines
+655
to
+658
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Security ceiling for lookup-for-update now derives from the current section rather than the first crumb
Was this helpful? React with 👍 or 👎 to provide feedback. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks — the analysis matches my reading. On the residual no-crumbs case: it only arises for |
||
| } | ||
|
|
||
| Property fp = new Property(); | ||
|
|
@@ -1003,12 +1007,6 @@ public PersistenceResponse add(PersistencePackageRequest request, boolean transa | |
| PersistencePackage pkg = persistencePackageFactory.create(request); | ||
| try { | ||
| if (request.isUpdateLookupType()) { | ||
| if (pkg.getSectionCrumbs() != null && pkg.getSectionCrumbs().length > 0) { | ||
| SectionCrumb sc = pkg.getSectionCrumbs()[0]; | ||
| if (StringUtils.isNotBlank(sc.getSectionIdentifier())) { | ||
| pkg.setSecurityCeilingEntityFullyQualifiedClassname(sc.getSectionIdentifier()); | ||
| } | ||
| } | ||
| if (transactional) { | ||
| return service.update(pkg); | ||
| } else { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| /*- | ||
| * #%L | ||
| * BroadleafCommerce Open Admin Platform | ||
| * %% | ||
| * Copyright (C) 2009 - 2026 Broadleaf Commerce | ||
| * %% | ||
| * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 | ||
| * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) | ||
| * unless the restrictions on use therein are violated and require payment to Broadleaf in which case | ||
| * the Broadleaf End User License Agreement (EULA), Version 1.1 | ||
| * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) | ||
| * shall apply. | ||
| * | ||
| * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") | ||
| * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. | ||
| * #L% | ||
| */ | ||
| package org.broadleafcommerce.openadmin.spec | ||
|
|
||
| import org.broadleafcommerce.common.exception.SecurityServiceException | ||
| import org.broadleafcommerce.openadmin.dto.PersistencePackage | ||
| import org.broadleafcommerce.openadmin.dto.SectionCrumb | ||
| import org.broadleafcommerce.openadmin.server.security.domain.AdminUser | ||
| import org.broadleafcommerce.openadmin.server.security.extension.AdminSecurityCheckExtensionManager | ||
| import org.broadleafcommerce.openadmin.server.security.remote.AdminSecurityServiceRemote | ||
| import org.broadleafcommerce.openadmin.server.security.remote.EntityOperationType | ||
| import org.broadleafcommerce.openadmin.server.security.service.AdminSecurityService | ||
| import org.broadleafcommerce.openadmin.server.security.service.RowLevelSecurityService | ||
| import org.broadleafcommerce.openadmin.server.security.service.type.PermissionType | ||
| import spock.lang.Specification | ||
|
|
||
| /** | ||
| * Verifies that admin entity authorization is performed against the security ceiling of the entity actually being | ||
| * operated on and cannot be satisfied by client supplied section crumbs. | ||
| */ | ||
| class AdminSecurityServiceRemoteSpec extends Specification { | ||
|
|
||
| static final String TARGET_CEILING = "org.broadleafcommerce.openadmin.server.security.domain.AdminUser" | ||
| static final String AUTHORIZED_CEILING = "org.broadleafcommerce.core.catalog.domain.Product" | ||
|
|
||
| AdminSecurityService securityService | ||
| AdminUser adminUser | ||
| AdminSecurityServiceRemote remoteService | ||
|
|
||
| def setup() { | ||
| securityService = Mock(AdminSecurityService) | ||
| adminUser = Mock(AdminUser) | ||
|
|
||
| //a real manager with no registered handlers always reports NOT_HANDLED | ||
| AdminSecurityCheckExtensionManager extensionManager = new AdminSecurityCheckExtensionManager() | ||
|
|
||
| AdminUser currentUser = adminUser | ||
| remoteService = new AdminSecurityServiceRemote() { | ||
| @Override | ||
| AdminUser getPersistentAdminUser() { | ||
| return currentUser | ||
| } | ||
| } | ||
| remoteService.securityService = securityService | ||
| remoteService.securityCheckExtensionManager = extensionManager | ||
| remoteService.rowLevelSecurityService = Mock(RowLevelSecurityService) | ||
| } | ||
|
|
||
| def "section crumbs cannot authorize an operation on an unrelated entity"() { | ||
| given: | ||
| PersistencePackage pkg = new PersistencePackage() | ||
| pkg.setCeilingEntityFullyQualifiedClassname(TARGET_CEILING) | ||
| pkg.setSectionCrumbs([crumb(AUTHORIZED_CEILING)] as SectionCrumb[]) | ||
|
|
||
| when: | ||
| remoteService.securityCheck(pkg, EntityOperationType.FETCH) | ||
|
|
||
| then: | ||
| 1 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, PermissionType.READ, TARGET_CEILING) >> false | ||
| 0 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, _, AUTHORIZED_CEILING) | ||
| thrown(SecurityServiceException) | ||
| } | ||
|
|
||
| def "a permission on the target ceiling authorizes the operation"() { | ||
| given: | ||
| PersistencePackage pkg = new PersistencePackage() | ||
| pkg.setCeilingEntityFullyQualifiedClassname(TARGET_CEILING) | ||
| pkg.setSectionCrumbs([crumb(AUTHORIZED_CEILING)] as SectionCrumb[]) | ||
|
|
||
| when: | ||
| remoteService.securityCheck(pkg, EntityOperationType.UPDATE) | ||
|
|
||
| then: | ||
| 1 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, PermissionType.UPDATE, TARGET_CEILING) >> true | ||
| notThrown(SecurityServiceException) | ||
| } | ||
|
|
||
| def "the explicit security ceiling takes precedence over the ceiling entity"() { | ||
| given: | ||
| PersistencePackage pkg = new PersistencePackage() | ||
| pkg.setCeilingEntityFullyQualifiedClassname(AUTHORIZED_CEILING) | ||
| pkg.setSecurityCeilingEntityFullyQualifiedClassname(TARGET_CEILING) | ||
|
|
||
| when: | ||
| remoteService.securityCheck(pkg, EntityOperationType.ADD) | ||
|
|
||
| then: | ||
| 1 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, PermissionType.CREATE, TARGET_CEILING) >> false | ||
| thrown(SecurityServiceException) | ||
| } | ||
|
|
||
| protected SectionCrumb crumb(String sectionIdentifier) { | ||
| SectionCrumb crumb = new SectionCrumb() | ||
| crumb.setSectionIdentifier(sectionIdentifier) | ||
| crumb.setSectionId("1") | ||
| return crumb | ||
| } | ||
|
|
||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Sub-collection requests now authorize against the collection entity, not the owning section
The PR description states "nested-collection requests continue to authorize against the owning section's ceiling as before", but that is only true for the LOOKUP_FOR_UPDATE path that now explicitly sets a security ceiling (
admin/broadleaf-open-admin-platform/src/main/java/org/broadleafcommerce/openadmin/server/service/AdminEntityServiceImpl.java:657). For ordinary sub-collection fetch/add/update/remove,PersistencePackageRequest.fromMetadatasets onlyceilingEntityClassnameto the collection ceiling (seeadmin/broadleaf-open-admin-platform/src/main/java/org/broadleafcommerce/openadmin/server/domain/PersistencePackageRequest.java:143-176andAdminEntityServiceImpl.java:395-437), andPersistencePackage.getSecurityCeilingEntityFullyQualifiedClassname()falls back to that ceiling. Previously the section crumb ceiling (the owning section entity) also satisfied the check via the OR loop; now it does not. OSS seed data incore/broadleaf-framework/src/main/resources/config/bc/sql/load_admin_permissions.sqldoes register most collection entities (CategoryXrefImpl, ProductOptionXref, SkuAttribute, ...), so out-of-the-box flows should keep working, but downstream projects with custom collections that were never registered in BLC_ADMIN_PERMISSION_ENTITY will start getting SecurityServiceException on collection screens. Worth calling out explicitly in release notes/upgrade docs.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct, and that tightening is inherent to the fix: any path that previously passed only because a crumb ceiling satisfied the OR now requires permission on the target ceiling. OSS seed data covers the standard collection entities, but downstream projects with unregistered custom collection entities will need BLC_ADMIN_PERMISSION_ENTITY records. I've called this out in the PR description as an upgrade note.