GET /api/v1/roles/layouts is reachable without authentication
@hassandotcms is already working on this.
Since Aug 31, 2026.
- Dominant language
- Java
- Stars
- 970
- Forks
- 486
- Avg merge
- 3d 33m
- Merged PRs (30d)
- 170
Description
Problem Statement
GET /api/v1/roles/layouts (RoleResource#getAllLayouts) has no authentication gate. Any unauthenticated caller receives 200 OK and the full tool-group catalog of the instance.
It is the only one of the 16 endpoints in RoleResource without a WebResource.InitBuilder block. Every sibling opens with:
new WebResource.InitBuilder(this.webResource)
.requiredFrontendUser(false).rejectWhenNoUser(true)
.requiredBackendUser(true).requiredPortlet("roles")
.requestAndResponse(request, response).init();
getAllLayouts goes straight to APILocator.getLayoutAPI().findAllLayouts().
What is exposed: every tool group's id, name, description, tabOrder, its full portletIds list, and the localized portletTitles. In effect, the complete map of the instance's administrative surface — including custom portlets, whose names are often customer- and business-specific.
Impact: information disclosure. No authentication, escalation, or data modification is possible through this endpoint — the sibling write endpoints (POST/DELETE /v1/roles/layouts) are properly gated. The value to an attacker is reconnaissance: knowing which features an instance runs, what they are called, and the real UUIDs those write endpoints consume.
Not protected by anything else. Three other layers were checked and none of them cover it:
| Layer | Result |
|---|---|
| Class-level annotation | RoleResource carries only @Tag(name = "Roles") and @SuppressWarnings |
Jersey RequestFilter |
Only wires session/context for InitRequestRequired. No throw, no abortWith, no 401 |
web.xml filters |
Nothing mapped under /api/ except the GraphQL servlet |
The method also survives an anonymous call: it invokes getLoggedInUser(request), which returns null, but LanguageUtil.get(User, String) falls back to the default company user (LanguageUtil.java:209) rather than throwing.
Steps to Reproduce
Against any running instance, with no credentials and no cookies:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/roles/layouts
Actual: 200, ~2.8 kB of JSON.
Expected: 401, consistent with every other endpoint on this resource.
Control run on the same instance, same conditions — proving authentication is working and this endpoint simply lacks it:
HTTP 200 /api/v1/roles/layouts <-- no gate
HTTP 401 /api/v1/roles
HTTP 401 /api/v1/roles/_search
HTTP 401 /api/v1/users/filter
Response excerpt:
10 tool groups
- Getting Started | id 2df9f117-b140-44bf-93d7-5b10a36fb7f9 | Welcome
- CMS Admin | id 557bd42d-e1cc-42d7-b529-a434dc40d607 |
- Site | id b7ab5d3c-5ee0-4195-a17e-8f5579d718dd | Pages, Browser, Templates, Containers, Publishing Queue
- Dashboard | id 34885ddb-3537-4a79-a02c-0550c5087d5c | Dashboard, Tasks
Origin
Introduced in 540335256b (2023-05-26, PR #24997), first shipped in v23.06. The method body is unchanged since — only Swagger annotations were added later in 9b57d68ff3.
The evidence points to an oversight rather than a deliberate choice: saveRoleLayouts was added in that same commit and did receive the full gate — InitBuilder plus a doesUserHaveRole(user, loadCMSAdminRole()) check. The write path was protected and the read path was not. The commit's subject is "Fix #24801 Adding a fix to handle content type permission inheritance", so the layout endpoints arrived as supporting changes inside a PR reviewing an unrelated concern.
Acceptance Criteria
-
getAllLayoutsinitializes aWebResource.InitBuilderwith the same gate its siblings use:requiredBackendUser(true),requiredPortlet("roles"),rejectWhenNoUser(true) - An unauthenticated
GET /api/v1/roles/layoutsreturns401 - An authenticated back-end user without
rolesportlet access returns401 - An authenticated back-end user with
rolesportlet access still receives the same200payload as today — response shape unchanged, includingportletTitles - An integration test covers the unauthenticated and unauthorized cases, so the gate cannot be dropped again unnoticed
- The remaining
RoleResourceendpoints are audited for the same gap and any found are either fixed or documented as intentionally public - A decision is recorded on whether this needs backporting to supported LTS lines, given the endpoint has been public since v23.06
dotCMS Version
All versions from v23.06 onward, including current main. Reproduced against a local build of main.
Severity
Medium - Some functionality impacted
Links
NA
Notes for whoever picks this up
The fix itself is a few lines — copy the InitBuilder block from any sibling in the same file. Two things are worth deciding beyond that:
- Consumer check before merging. The Angular Roles (Beta) portlet now calls this endpoint directly through
DotRolesService.getAllToolGroups(), and the legacy Dojo portlet reaches it from inside its iframe. Both run as authenticated back-end users withrolesaccess, so neither should break — but it is worth confirming rather than assuming, since the endpoint was previously reachable from any context. - Scope of the audit. This was found by reading one resource. The same class of gap may exist elsewhere; a broader sweep for JAX-RS methods lacking
InitBuilderwould be a reasonable follow-up, tracked separately.
Runtime behaviour was verified against a local instance. What was not verified is whether a production deployment's reverse proxy or WAF blocks the path independently — that does not change the defect, but it may change how exposed any given environment actually is.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.