The REST API getUsers endpoint in StudioCMS uses the attacker-controlled rank query parameter to decide whether owner accounts should be filtered from the result set. As a result, an admin token can request rank=owner and receive owner account records, including IDs, usernames, display names, and email addresses, even though the adjacent getUser endpoint correctly blocks admins from viewing owner users. This is an authorization inconsistency inside the same user-management surface.
File: D:/bugcrowd/studiocms/repo/packages/studiocms/frontend/pages/studiocms_api/_handlers/rest-api/v1/secure.ts, lines 1605-1647
.handle(
'getUsers',
Effect.fn(
function* ({ urlParams: { name, rank, username } }) {
if (!restAPIEnabled) {
return yield* new RestAPIError({ error: 'Endpoint not found' });
}
const [sdk, user] = yield* Effect.all([SDKCore, CurrentRestAPIUser]);
if (user.rank !== 'owner' && user.rank !== 'admin') {
return yield* new RestAPIError({ error: 'Unauthorized' });
}
const allUsers = yield* sdk.GET.users.all();
let data = allUsers.map(...);
if (rank !== 'owner') {
data = data.filter((user) => user.rank !== 'owner');
}
if (rank) {
data = data.filter((user) => user.rank === rank);
}
return data;
},
The rank variable in if (rank !== 'owner') is the request query parameter, not the caller's privilege level. An admin can therefore pass rank=owner, skip the owner-filtering branch, and then have the second if (rank) branch return only owner accounts.
File: D:/bugcrowd/studiocms/repo/packages/studiocms/frontend/pages/studiocms_api/_handlers/rest-api/v1/secure.ts, lines 1650-1710
const existingUserRankIndex = availablePermissionRanks.indexOf(existingUserRank);
const loggedInUserRankIndex = availablePermissionRanks.indexOf(user.rank);
if (loggedInUserRankIndex <= existingUserRankIndex) {
return yield* new RestAPIError({
error: 'Unauthorized to view user with higher rank',
});
}
getUser correctly blocks an admin from viewing an owner record. getUsers bypasses that boundary for bulk enumeration.
The getUsers response includes:
idemailnameusernamerankThis is enough to enumerate all owner accounts and target them for phishing, social engineering, or follow-on attacks against out-of-band workflows.
Use any admin-level REST API token:
curl -X GET 'http://localhost:4321/studiocms_api/rest/v1/secure/users?rank=owner' \
-H 'Authorization: Bearer <admin-api-token>'
Expected behavior:
- owner records should be excluded for admin callers, consistent with getUser
Actual behavior: - the response contains owner user objects, including email addresses and user IDs
I validated the filtering logic locally with the same conditions used by getUsers and getUser.
Observed output:
{
"admin_getUsers_rank_owner": [
{
"email": "owner@example.test",
"id": "owner-1",
"name": "Site Owner",
"rank": "owner",
"username": "owner1"
}
],
"admin_getUser_owner": "Unauthorized to view user with higher rank"
}
This demonstrates the authorization mismatch clearly:
- bulk listing with rank=owner exposes owner records
- direct access to a single owner record is denied
getUser.Apply rank filtering based on the caller's role, not on the request query parameter, and reuse the same privilege rule as getUser.
Example fix:
const loggedInUserRankIndex = availablePermissionRanks.indexOf(user.rank);
data = data.filter((candidate) => {
const candidateRankIndex = availablePermissionRanks.indexOf(candidate.rank);
return loggedInUserRankIndex > candidateRankIndex;
});
if (rank) {
data = data.filter((candidate) => candidate.rank === rank);
}
At minimum, replace:
if (rank !== 'owner') {
data = data.filter((user) => user.rank !== 'owner');
}
with a check tied to user.rank rather than the query parameter.
{
"nvd_published_at": "2026-03-18T21:16:26Z",
"github_reviewed_at": "2026-03-16T16:37:42Z",
"cwe_ids": [
"CWE-639"
],
"severity": "LOW",
"github_reviewed": true
}