sec: enforce admin check on layout assignment and role grant endpoints - #36344
Conversation
ToolGroupResource._addtouser and ._removefromuser now require the caller to be a CMS Administrator before assigning or removing any layout other than the gettingStarted onboarding layout. Previously any backend user could self-assign the admin Settings layout (which contains the roles portlet), bypassing the intended privilege boundary. RoleAjax.addUserToRole and .removeUsersFromRole now require the caller to be a CMS Administrator in addition to the existing portlet-access check. Previously a user who had gained roles-portlet access could grant themselves (or any user) any role including CMS Administrator via DWR. Together these two gaps formed a privilege-escalation chain that allowed any authenticated backend user to reach CMS Administrator and subsequently execute arbitrary OS commands via OSGi bundle upload. Closes: dotCMS/private-issues#640 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude finished @mbiuki's task in 2m 43s —— View job Code Review — PR #36344
I reviewed the diff against New IssuesNo issues found. The guard
Resolved
Notes (non-blocking)
|
🤖 dotBot Review (Bedrock)Reviewed 2 file(s); 1 candidate(s) → 1 confirmed, 0 uncertain (unverified, kept for review). Confirmed findings
us.deepseek.r1-v1:0 · Run: #28671877655 · tokens: in: 8448 · out: 2919 · total: 11367 · calls: 4 · est. ~$0.027 |
|
This PR fixes https://github.com/dotCMS/private-issues/issues/642 |
PRs linked to this issue |
Versions AffectedExploitable range: v21.02 → v26.06.22-03 (current latest)
No patched release has been cut yet — this PR is the fix. |
Vulnerability Introduction Timeline
|
| Date | Commit | Author | Event |
|---|---|---|---|
| 2020-12-16 | 8aa87f7b87 |
Will Ezell | Method created without any admin check (PR #19680, issue #19581) — only requiredBackendUser(true) enforced |
| 2021-03-22 | 890feffa94 |
Nollymar Longa | ?userid= parameter added — widened exploit surface from self-assignment to assigning layouts to any user |
| 2025-11-04 | 3babaf0d17 |
— | Partial portlet-level mitigation added (private-issues#482) — insufficient alone |
| PR #36344 | — | — | Full fix: admin-only gate added — not yet in any release |
Affected Versions
- First vulnerable release:
21.02(December 2020 commit shipped in the Feb 2021 release cut) - Widened attack surface (other-user assignment): ~
21.03–21.04(March 2021 commit) - All affected:
21.02→v26.06.22-03(current latest), including all LTS lines:- 23.10 LTS ✗
- 24.04 LTS ✗
- 24.12 LTS ✗
- 25.07 LTS ✗
- Partial mitigation from:
v25.11.07-1(still exploitable via chain with sec: enforce admin check on layout assignment and role grant endpoints #36344) - Fully fixed in: not yet released
🔴 Red Team Review — PR #36344Three independent reviewers examined this PR across security, performance, and test coverage. Verdicts
🔐 SecurityThe 🚨 Critical —
|
| Scenario | Status |
|---|---|
Non-admin blocked on _addtouser |
✅ |
Admin succeeds on _addtouser |
✅ |
Non-admin blocked on addUserToRole (with portlet access) |
✅ |
gettingStarted non-admin self-assignment allowed |
❌ Missing |
Non-admin blocked on _removefromuser |
❌ Missing |
Admin succeeds on _removefromuser |
❌ Missing |
Non-admin blocked on removeUsersFromRole |
❌ Missing |
Admin succeeds on addUserToRole / removeUsersFromRole |
❌ Missing |
Cross-user ?userid= assignment blocked for non-admin |
❌ Missing |
SecurityLogger invoked on unauthorized attempts |
❌ Missing |
Recommended additions:
// ToolGroupResourceTest
test_removeToolGroupFromUser_lowPrivilegeUser_throwsSecurityException()
test_removeToolGroupFromUser_adminUser_succeeds()
test_addToolGroupToUser_gettingStartedLayout_nonAdmin_succeeds() // if exception is restored
test_addToolGroupToUser_crossUserAssignment_lowPrivilegeUser_throwsSecurityException()
// RoleAjaxSecurityTest
test_removeUsersFromRole_lowPrivilegeUser_throwsSecurityException()
test_addUserToRole_adminUser_succeeds()
test_removeUsersFromRole_adminUser_succeeds()Summary
The _addtouser fix is correct and closes Step 1 of the escalation chain. Three blockers must be resolved before merge:
- 🚨 Add admin guard to
_removefromuser ⚠️ ReplacegetAdminUser()withisAdmin()inaddUserToRole+removeUsersFromRole⚠️ RestoreSecurityLoggerwith accurate action descriptions on unauthorized role-assignment attempts
Move the isAdmin() check above the loadUserById() call in both _removefromuser and _addtouser so unauthorized callers are rejected before any DB round-trip for the target user. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHg1W7beD4Z1yLoTJpyXss
|
Correction to the Red Team Review above After reading the actual PR commit (
The review was based on pre-PR baseline code rather than the PR branch — apologies for the noise. One real issue was found and fixed: both |
Covers the four new admin checks: RoleAjax.addUserToRole / removeUsersFromRole reject non-admin callers even with roles-portlet permission, ToolGroupResource add/remove reject non-admins, the gettingstarted layout exemption still works for non-admins, and admin callers pass through with the expected API side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHg1W7beD4Z1yLoTJpyXss
✅ Test ResultsAdded unit tests for all four admin gates in this PR (commit 61527b0) and ran them locally:
Run locally with: Also: the 🤖 Generated with Claude Code |
|
Findings 🔴 ToolGroupResource.java:79 — _removefromuser gains an unconditional admin gate, but _addtouser exempts gettingstarted; this breaks the non-admin "Getting Started" toggle. The add path is guarded as !layoutId.equalsIgnoreCase("gettingstarted") && !loggedInUser.isAdmin(), deliberately letting non-admins self-assign the onboarding layout. The remove path, however, is if (!loggedInUser.isAdmin()) throw ... with no Two non-admin-reachable call sites hit _removefromuser/gettingstarted via DotAccountService.removeStarterPage():
Failure scenario: A non-admin backend user enables the Getting Started page (succeeds — add exempts gettingstarted), then toggles it off. deleteToolGroupFromUser throws DotSecurityException → the httpErrorManagerService catch in removeStarterPage() 🟠 ToolGroupResourceTest.java:132 — the new test codifies the regression rather than catching it. deleteToolGroupFromUser_rejectsNonAdmin asserts a non-admin is rejected for "someLayout", but there is no test for a non-admin removing gettingstarted (the |
dsilvam found that _addtouser exempts the "gettingstarted" layout from the admin check but _removefromuser did not, so a non-admin could enable the Getting Started page but got a DotSecurityException dismissing it (reachable from My Account and the onboarding portlet toggles). Mirror the exemption on the remove path, and tighten both paths so the gettingstarted exemption is self-service only — targeting another userid still requires admin (also addresses the dotBot userid-validation finding on the add path). Extracted the shared check into isSelfServiceGettingStarted(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHg1W7beD4Z1yLoTJpyXss
|
@dsilvam you're right — validated and fixed in 0edd733. Thank you, this was a real regression. Confirmed your analysis end to end:
Fix: mirrored the exemption on the remove path. I also tightened it a bit beyond a pure mirror — the exemption is now self-service only ( Tests (your second point — the suite now covers the symmetry):
🤖 Generated with Claude Code |
|
Queued — the merge queue status continues in this comment ↓. |
Merge Queue Status
This pull request spent 1 minute 20 seconds in the queue, including 5 seconds running CI. Required conditions to merge
ReasonPull request #36344 has been dequeued GitHub refused to merge the pull request. Pull Request is in the merge queue. This is usually enforced by a branch protection or ruleset rule. HintYou should look at the reason for the failure and decide if the pull request needs to be fixed or if you want to requeue it. Tick the box to put this pull request back in the merge queue (same as
|
Summary
Fixes a privilege-escalation chain (reported via responsible disclosure) that allowed any authenticated backend user to reach CMS Administrator and subsequently achieve remote code execution via OSGi bundle upload.
Attack chain closed by this PR:
PUT /api/v1/toolgroups/{id}/_addtouser— any backend user could self-assign the admin Settings layout (containing therolesportlet) with no privilege checkPOST /dwr/call/plaincall/RoleAjax.addUserToRole.dwr— with therolesportlet now accessible, the user could grant themselves the CMS Administrator role; the only gate was portlet-access, which step 1 satisfiedChanges
ToolGroupResource.java_addtouser: requireloggedInUser.isAdmin()for all layouts exceptgettingstarted(the onboarding layout — intentionally self-assignable)_removefromuser: same admin guard applied symmetricallyRoleAjax.javaaddUserToRole: addcaller.isAdmin()check after the existing portlet-access check; log unauthorized attempts viaSecurityLoggerremoveUsersFromRole: same admin guard applied symmetricallyTest plan
PUT /api/v1/toolgroups/{layout_id}/_addtouser(non-gettingStarted layout)gettingStartedlayout self-assignment still works for non-admin users (onboarding flow must not break)RoleAjax.addUserToRolevia DWRReferences
Closes dotCMS/private-issues#642
🤖 Generated with Claude Code