Skip to content

sec: harden portlet-only gated methods in RoleAjax - #36345

Open
mbiuki wants to merge 17 commits into
mainfrom
sec/role-ajax-portlet-gate-hardening
Open

mbiuki wants to merge 17 commits into
mainfrom
sec/role-ajax-portlet-gate-hardening

Conversation

@mbiuki

@mbiuki mbiuki commented Jun 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Follow-on to #36344. The RoleAjax DWR methods that change role layouts, layouts and role permissions now require a CMS Administrator, the same bar addUserToRole / removeUsersFromRole already use. Rejected calls are written to the security log.

Method Before After
saveRolePermission Roles portlet only Roles portlet + CMS Administrator
saveRoleLayouts Roles portlet + Users portlet (getAdminUser()), checked after two DB reads Roles portlet + CMS Administrator, checked before any DB read
updateLayout Roles portlet + Users portlet (getAdminUser()) Roles portlet + CMS Administrator
deleteLayout Roles portlet + Users portlet (getAdminUser()) Roles portlet + CMS Administrator
addUserToRole / removeUsersFromRole Roles portlet + CMS Administrator (inline, from #36344) Same behaviour, now through the shared helper

getAdminUser() checks PortletAPI.hasUserAdminRights, which is Users portlet access, not CMS Administrator, so it is no longer used as the gate for these four methods.

Changes

RoleAjax.java (the only production change)

  • New private validateCMSAdmin(action): Roles portlet check, then isAdmin(); a non-admin caller gets a SecurityLogger entry naming the operation and user, and a DotSecurityException.
  • Used by saveRoleLayouts, updateLayout, deleteLayout and saveRolePermission, and replaces the inline copies in addUserToRole / removeUsersFromRole.

Behaviour change

Non-admin users who hold the Roles and Users portlets could previously save role layouts and update or delete layouts, and anyone with the Roles portlet could save role permissions. They now get DotSecurityException. CMS Administrators are unaffected. The only callers of these four DWR methods are the legacy Roles & Tools screens (view_roles_js_inc.jsp, view_role_permissions_js_inc.jsp).

Scope

  • ToolGroupResource is intentionally not changed. An earlier revision added .requiredRoles(CMS_ADMINISTRATOR_ROLE) to addToolGroupToUser; it was reverted because requiredRoles runs before the method body and would block non-admin Getting Started onboarding (PUT /api/v1/toolgroups/gettingstarted/_addtouser). Those endpoints are handled by sec: enforce admin check on layout assignment and role grant endpoints #36344. ToolGroupResource matches main.
  • The other getAdminUser() callers (addNewRole, updateRole, deleteRole, lockRole, unlockRole, addNewLayout) are not changed here.

Tests

RoleAjaxSecurityTest (integration, MainSuite2b) runs as a non-admin backend user holding both the Roles and the Users portlets, so the Users portlet check would pass and only the CMS Administrator check can reject the call:

  • test_addUserToRole_nonAdmin_throwsDotSecurityException
  • test_saveRolePermission_nonAdmin_throwsDotSecurityException
  • test_saveRoleLayouts_nonAdmin_throwsDotSecurityException
  • test_updateLayout_nonAdmin_throwsDotSecurityException
  • test_deleteLayout_nonAdmin_throwsDotSecurityException

RoleAjaxTest (unit) adds *_rejectsNonAdmin for the four methods, asserting DotSecurityException and a SecurityLogger entry. Run locally: 7/7 pass, and the four new tests fail against the previous getAdminUser() gate. The system-user DWR callers in LayoutAPITest / PermissionAPITest are unaffected: the system user always holds the CMS Administrator role.

Test plan

  • Non-admin with Roles + Users portlet access gets DotSecurityException from saveRolePermission, saveRoleLayouts, updateLayout and deleteLayout via DWR, with an entry in the security log
  • CMS Administrator can save role layouts, update and delete layouts, and save role permissions in Roles & Tools
  • Getting Started onboarding (_addtouser / _removefromuser of gettingstarted) still works
  • LayoutAPITest and PermissionAPITest still pass in MainSuite 2b

References

🤖 Generated with Claude Code

saveRolePermission had no admin check at all — only the portlet-access
gate. Added getAdminUser() to enforce the same admin requirement used by
addNewRole/updateRole/deleteRole/lockRole/unlockRole.

saveRoleLayouts already called getAdminUser() but after two DB reads.
Moved the call to immediately after the portlet check so the admin gate
fires before any data access, consistent with the pattern in updateLayout
and deleteLayout.

updateLayout and deleteLayout were already correctly ordered — no change.

Closes: dotCMS/private-issues#643

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@mbiuki mbiuki self-assigned this Jun 28, 2026
@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label Jun 28, 2026
@mbiuki mbiuki added Team : Scout Team : Security Issues related to security and privacy and removed Area : Backend PR changes Java/Maven backend code labels Jun 28, 2026
@claude

claude Bot commented Jun 28, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @mbiuki's task in 1m 44s —— View job


SDK Compatibility Check

  • Read docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md
  • Diffed c444a240...fbf894f0 (full PR range)
  • Checked diff against every category (G-1/G-2/G-3, R-1, U-1/U-2, H-1)
  • Determination: no SDK breaking change

Scope of the diff: all four changed files are server-side Java/integration-test code —
dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java (new private
validateCMSAdmin(action) gate used by saveRolePermission, saveRoleLayouts, updateLayout,
deleteLayout, addUserToRole, removeUsersFromRole), dotCMS/src/test/java/.../RoleAjaxTest.java,
dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java, and the new
dotcms-integration/src/test/java/.../RoleAjaxSecurityTest.java.

None of it touches an SDK-reachable surface:

  • No GraphQL schema/resolver changes (graphql.page / graphql.content) — G-1/G-2/G-3 n/a.
  • No /api/v1/nav, /api/v1/content, or /api/v1/page/* response shape changes — R-1 n/a.
  • No UVE postMessage protocol changes (__DOTCMS_UVE_EVENT__ / DotCMSUVEAction) — U-1/U-2 n/a.
  • No changes to SdkVersionWebInterceptor, X-DotCMS-Version/X-DotCMS-Min-SDK, or
    compareVersions()/sdk-compatibility.ts — H-1 n/a.

The only runtime-reachable surface here is the legacy DWR endpoint RoleAjax, which backs the
Roles & Tools JSPs (view_roles_js_inc.jsp, view_role_permissions_js_inc.jsp), not any
@dotcms/* SDK. This matches the "admin-UI only change" / "internal refactor, no wire-format
change" non-breaking examples in the reference doc.

No PR comment or SDK Breaking Change label was added, per the check's instructions for a
non-breaking result.

@github-actions

github-actions Bot commented Jun 28, 2026 •

Copy link
Copy Markdown
Contributor

🤖 dotBot Review (Bedrock)

Reviewed 5 file(s); 3 candidate(s) → 2 confirmed, 0 uncertain (unverified, kept for review).

Confirmed findings

  • 🔴 Critical dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java:573 — Admin check bypass in role layout updates
    Removal of 'User user = getAdminUser()' assignment causes subsequent permission check to use logged-in user instead of admin, allowing non-admins with roles portlet access to modify role layouts. The permission check at line 573 now references an undefined 'user' variable, which likely resolves to the current user rather than an admin.
  • 🟡 Medium dotcms-integration/src/test/java/com/dotcms/rest/api/v1/portlet/ToolGroupResourceTest.java:60 — Silent exception swallowing in test cleanup
    In ToolGroupResourceTest.java line 60, exceptions during test role cleanup are caught but not logged or reported. This could mask failures in test teardown that might lead to data leaks between tests. While test cleanup is often best-effort, silent swallowing makes debugging harder when dependencies exist between tests.

us.deepseek.r1-v1:0 · Run: #28672054880 · tokens: in: 21925 · out: 6849 · total: 28774 · calls: 10 · est. ~$0.067

@mbiuki
mbiuki requested review from nollymar and wezell June 28, 2026 18:30
@mbiuki mbiuki moved this to Next Sprint in dotCMS - Product Planning Jun 28, 2026
@mbiuki

mbiuki commented Jun 28, 2026

Copy link
Copy Markdown
Member Author

Versions Affected

Exploitable range: v21.02 → v26.06.22-03 (current latest)

Gap Introduced First vulnerable release
RoleAjax.saveRolePermission (portlet-only gate, no admin check) March 2012 v3.0
ToolGroupResource._addtouser (enabling step) December 2020 v21.02

No patched release has been cut yet — this PR is part of the fix.

…#642)

Any authenticated backend user could call PUT /api/v1/toolgroups/{id}/_addtouser
to self-assign arbitrary layouts, including the admin Settings layout containing
the roles/users portlets. Combined with the pre-existing DWR addUserToRole check
(getAdminUser()), this broke the full privilege-escalation + RCE chain.

Adds .requiredRoles(Role.CMS_ADMINISTRATOR_ROLE) to the _addtouser InitBuilder so
non-admin callers receive a 403/SecurityException before any layout operation runs.

Also adds regression tests:
- ToolGroupResourceTest: verifies 403 for non-admin, success for admin
- RoleAjaxSecurityTest: verifies addUserToRole blocks a non-admin who already holds
  roles-portlet access (post-step-1 state), confirming independent defense-in-depth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@mbiuki

mbiuki commented Jun 29, 2026

Copy link
Copy Markdown
Member Author

CI Re-run Results ✅

All checks passed on the re-run (run #28331839580).

The previous failures (QuartzUtils DB deadlock, StoryBlockMap NPE, E2E/Postman timeouts) were confirmed as pre-existing flaky infrastructure issues unrelated to this PR's changes.

This PR is clear to merge.

Suite Result
Integration Tests - MainSuite 1a ✅ pass
Integration Tests - MainSuite 1b ✅ pass
Integration Tests - MainSuite 2a/2b/3a ✅ pass
Integration Tests - Junit5 Suite 1 ✅ pass
Integration Tests - OpenSearch Upgrade ✅ pass
E2E Tests - Nx Playwright ✅ pass
Postman Tests - GraphQL ✅ pass
All other Postman/Karate/CLI/JVM suites ✅ pass

@mbiuki mbiuki closed this Jun 29, 2026
@github-project-automation github-project-automation Bot moved this from Next Sprint to Done in dotCMS - Product Planning Jun 29, 2026
@mbiuki mbiuki reopened this Jun 29, 2026
@wezell

wezell commented Jun 29, 2026

Copy link
Copy Markdown
Member

@mbiuki @sfreudenthaler we should do a pass over all the DWR endpoints to make sure there are no other vulnerabilities. These all predate any of our security hardening efforts. Here is a list of these endpoints.

https://github.com/dotCMS/core/blob/main/dotCMS/src/main/webapp/WEB-INF/dwr.xml

@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code and removed AI: Safe To Rollback labels Jun 29, 2026
prepare() never adds testLayout to adminUser's role; only the admin
success test does via the API call. Wrapping in try-catch prevents a
guaranteed DotDataException on teardown when that test is skipped or
fails before the association is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread dotCMS/src/main/java/com/dotcms/rest/api/v1/portlet/ToolGroupResource.java Outdated
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ AI review failed

Claude did not produce a review — the backend call errored before generating any output (provider: anthropic-bedrock, model: global.anthropic.claude-sonnet-5). This usually means the model has no Bedrock access grant in the target account, or the model ID is invalid — not a problem with this PR.

Run: #37488524063

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ AI review failed

Claude did not produce a review — the backend call errored before generating any output (provider: anthropic-bedrock, model: global.anthropic.claude-sonnet-5). This usually means the model has no Bedrock access grant in the target account, or the model ID is invalid — not a problem with this PR.

Run: #37507833153

ihoffmann-dot
ihoffmann-dot previously approved these changes Oct 9, 2026

@ihoffmann-dot ihoffmann-dot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved ✅

This hardening change looks good.

What it does

  • Restores the admin-only check on saveRolePermission, which had been dropped by mistake. Users with only roles-portlet access can no longer change role permissions.
  • Applies the same admin check in saveRoleLayouts before anything is loaded, so unauthorized callers are rejected right away.

Why I'm comfortable approving

  • It matches how the other role-modifying endpoints in RoleAjax are already protected.
  • The change is small and contained, with no side effects on the rest of the class.
  • The new RoleAjaxSecurityTest covers the three affected paths with a non-admin user who only has roles-portlet access, and it is registered in MainSuite2b, so it runs in CI.
  • The test cleans up after itself, so it won't interfere with other tests in the suite.

Non-blocking note: the read-only role endpoints (e.g. getRole, getUserRole, getRolePermissions) still use the portlet-only check. That looks intentional and is outside the scope of this PR.

@erickgonzalez
erickgonzalez added this pull request to the merge queue Oct 9, 2026
Comment thread dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java Outdated
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 9, 2026
…ission changes

saveRoleLayouts, updateLayout, deleteLayout and saveRolePermission were
gated by getAdminUser(), which only checks Users portlet access. They now
require a CMS Administrator, the same bar addUserToRole and
removeUsersFromRole already use, and rejected calls are written to the
security log.

- RoleAjax: add validateCMSAdmin (Roles portlet + isAdmin + SecurityLogger)
  and use it in all six role and layout mutators
- RoleAjaxSecurityTest: describe behaviour rather than history, rename
  tests to *_nonAdmin_throwsDotSecurityException, cover updateLayout and
  deleteLayout, and grant the Users portlet so only the CMS Administrator
  check can reject the calls
- RoleAjaxTest: non-admin rejection and security log entry for the four
  methods
- MainSuite2b: import order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mbiuki

mbiuki commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

@oidacra review again

@mbiuki

mbiuki commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

@ihoffmann-dot review again

@swicken swicken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pulled the branch and ran RoleAjaxTest locally: 7 of 7 green. The new helper runs before any of the four methods read or write anything, the REST counterparts in RoleResource and LayoutResource already apply the same CMS Administrator bar, and the only DWR callers are the two Roles & Tools JSPs, as the description says. A merge-tree against current main is clean; of the four files only MainSuite2b has moved on main, in unrelated lines, so the rebase is straightforward.

Two things in the integration test worth fixing before merge, and a couple of nits, on the lines below. I'll follow up separately on the PR wording and history before this goes in.

Comment thread dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java Outdated
Comment thread dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java Outdated
swicken
swicken previously approved these changes Oct 20, 2026

@swicken swicken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. The administrator check is in the right place and the behaviour change is documented; the two test comments above are suggestions, not blockers. When merging, please squash with a single plain commit message rather than the default concatenation.

mbiuki and others added 2 commits October 20, 2026 12:56
…usals at WARN

- RoleAjax.validateCMSAdmin: log refused calls at WARN with the remote address
- RoleAjaxSecurityTest: target a throwaway role for saveRolePermission and
  saveRoleLayouts, restore the DWR builder attached before the class ran
  instead of attaching a system-user context, and add a CMS Administrator
  case where updateLayout and deleteLayout go through
- RoleAjaxTest: share one helper for all six rejection tests and assert the
  WARN entry names the user and remote address
- MainSuite2b: fully qualified class name like its neighbours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@swicken swicken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed at fbf894f. The rejection tests now target a throwaway role and layout, the class saves the DWR context that was attached before it ran and puts that back afterwards, refusals log at WARN with the remote host, and the new administrator case actually exercises update and delete rather than only asserting that nothing was thrown. The merge from main kept main's additions to MainSuite2b, and the only production change since my last pass is the log line in validateCMSAdmin. Approving again.

This branch has not been deployed

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

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Team : Scout Team : Security Issues related to security and privacy

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

6 participants