Skip to content

Fixing admin entity authorization bypass via client-controlled sectionCrumbs - #97

Open
kevinlrd wants to merge 2 commits into
develop-7.0.xfrom
devin/1786586195-fix-admin-crumb-authz-bypass
Open

kevinlrd wants to merge 2 commits into
develop-7.0.xfrom
devin/1786586195-fix-admin-crumb-authz-bypass

Conversation

@kevinlrd

@kevinlrd kevinlrd commented Aug 13, 2026

Copy link
Copy Markdown

A Brief Overview

AdminSecurityServiceRemote.securityCheck(PersistencePackage, EntityOperationType) authorized an operation against the union of the target entity's security ceiling and the identifiers of the request's section crumbs, passing as soon as the user qualified for any one of them. Section crumbs come from the client-controlled sectionCrumbs request parameter and are only validated to name some registered entity, so an admin holding a single permission (e.g. UPDATE on Product) could append a crumb naming that entity and pass the check for any other entity — including creating/altering AdminUser/AdminRole/AdminPermission for full takeover.

Authorization is now performed solely against the entity actually being operated on:

// before: ceilingNames = {securityCeiling} U {crumb.sectionIdentifier ...}, OR over ceilings
// after:
securityCheck(persistencePackage.getSecurityCeilingEntityFullyQualifiedClassname(), operationType);

The multi-ceiling helper securityCheck(String[], EntityOperationType) now requires the user to qualify for every supplied ceiling (fails on the first that is not qualified) so no future caller can regain the OR behavior; the unregistered-entity diagnostic reports the ceiling that actually failed.

A variant of the same path is closed in AdminEntityServiceImpl: for AddMethodType.LOOKUP_FOR_UPDATE the security ceiling was overwritten with sectionCrumbs[0] (also client-controlled) inside add(...). It is now set in addSubCollectionEntity from the server-side ClassMetadata of the entity that owns the collection:

ppr.setUpdateLookupType(true);
ppr.withSecurityCeilingEntityClassname(isNotBlank(mainMetadata.getSecurityCeilingType())
        ? mainMetadata.getSecurityCeilingType() : mainMetadata.getCeilingType());

New AdminSecurityServiceRemoteSpec covers: a crumb naming an authorized entity no longer satisfies the check for an unrelated target (and the crumb ceiling is never queried), a permission on the target ceiling still passes, and an explicit security ceiling takes precedence over the ceiling entity.

Add Labels to the right panel: Security, Status (ready-for-code-review)

Additional context

Upgrade note — any request that previously passed only because a crumb ceiling satisfied the OR is now denied:

  • Admins must hold a permission on the ceiling of the entity they are operating on.
  • Sub-collection screens authorize against the collection entity's ceiling. OSS seed data registers the standard collection entities, but downstream projects with custom collection entities that were never added to BLC_ADMIN_PERMISSION_ENTITY will need those records.

Link to Devin session: https://app.devin.ai/sessions/ab99b537f9fd42ba9f7c18bf758975a7
Requested by: @kevinlrd


Devin Review

Status Commit
⚪ Not started

Run Devin Review

💡 Connect your GitHub account to enable automatic code reviews.

Open in Devin Review (Staging)
Open in Devin Review

…iling only

Client supplied sectionCrumbs could previously satisfy the admin entity
security check for any entity, allowing privilege escalation.

Co-Authored-By: Kevin Lee <kevin.lee@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

Comment on lines +131 to +134
//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();

Copy link
Copy Markdown

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.fromMetadata sets only ceilingEntityClassname to the collection ceiling (see admin/broadleaf-open-admin-platform/src/main/java/org/broadleafcommerce/openadmin/server/domain/PersistencePackageRequest.java:143-176 and AdminEntityServiceImpl.java:395-437), and PersistencePackage.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 in core/broadleaf-framework/src/main/resources/config/bc/sql/load_admin_permissions.sql does 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

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.

Comment on lines +655 to +658
//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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 AddMethodType.LOOKUP_FOR_UPDATE the persistence operation is a full update of the looked-up target record, yet authorization is now performed against the owning entity's ceiling (mainMetadata), mirroring the old sectionCrumbs[0] behavior. The client-controlled input is removed (good), but a user holding only UPDATE on the owning section can still mutate the target entity without holding permission on it. There are no OSS usages of LOOKUP_FOR_UPDATE, so this is latent; consider whether the target ceiling should additionally be checked (the new AND-semantics helper would support passing both).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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).

Co-Authored-By: Kevin Lee <kevin.lee@cognition.ai>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +655 to +658
//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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

mainMetadata in all three addSubCollectionEntity callers (AdminBasicEntityController selectize-add, add, addEmpty) is obtained via getSectionPersistencePackageRequest(mainClassName, ...), and AdminEntityServiceImpl.getClassMetadata (line 111) always populates securityCeilingType (it falls back to the ceiling class), so the isNotBlank(...) ? ... : getCeilingType() fallback here is effectively dead but harmless. Behaviorally, the ceiling used is now the current section entity, whereas the removed code in add(...) used sectionCrumbs[0], which after getSectionCrumbs appends the current section last is the outermost (client-supplied) crumb — i.e. this is a genuine tightening, not merely a source change. One residual difference: when no crumbs were present the old code left the security ceiling as the looked-up target class; now the owning entity's ceiling is always used, which is slightly more permissive in that (rare) case.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 LOOKUP_FOR_UPDATE, which has no OSS usages, and the alternatives (leaving the ceiling as the looked-up target, or requiring both ceilings via the AND helper) are behavior decisions that would deny flows working today, so I'm surfacing them to the maintainers rather than deciding here. The fallback to getCeilingType() is defensive only, consistent with other callers.

Comment on lines +185 to 189
/**
* 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 securityCheck(String[]) through the single-string overload, so the AND semantics are currently unobservable outside tests. The method remains protected, meaning subclasses in downstream modules that previously relied on the OR ("qualified for any ceiling") contract will silently get inverted behavior. Since no in-repo caller passes multiple ceilings, consider making the array overload private or renaming it to make the new contract explicit for downstream extenders.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally left protected — narrowing visibility would be a source-breaking change for downstream extenders, and the inverted contract is the point of the fix (a subclass relying on the OR contract is relying on the vulnerability). The javadoc states the new AND semantics explicitly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant