+ "details": "## Summary\n\nThe 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.\n\n## Details\n\n### Vulnerable Code Path\n\nFile: `D:/bugcrowd/studiocms/repo/packages/studiocms/frontend/pages/studiocms_api/_handlers/rest-api/v1/secure.ts`, lines 1605-1647\n\n```ts\n.handle(\n 'getUsers',\n Effect.fn(\n function* ({ urlParams: { name, rank, username } }) {\n if (!restAPIEnabled) {\n return yield* new RestAPIError({ error: 'Endpoint not found' });\n }\n const [sdk, user] = yield* Effect.all([SDKCore, CurrentRestAPIUser]);\n\n if (user.rank !== 'owner' && user.rank !== 'admin') {\n return yield* new RestAPIError({ error: 'Unauthorized' });\n }\n\n const allUsers = yield* sdk.GET.users.all();\n let data = allUsers.map(...);\n\n if (rank !== 'owner') {\n data = data.filter((user) => user.rank !== 'owner');\n }\n\n if (rank) {\n data = data.filter((user) => user.rank === rank);\n }\n\n return data;\n },\n```\n\nThe `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.\n\n### Adjacent Endpoint Shows Intended Security Boundary\n\nFile: `D:/bugcrowd/studiocms/repo/packages/studiocms/frontend/pages/studiocms_api/_handlers/rest-api/v1/secure.ts`, lines 1650-1710\n\n```ts\nconst existingUserRankIndex = availablePermissionRanks.indexOf(existingUserRank);\nconst loggedInUserRankIndex = availablePermissionRanks.indexOf(user.rank);\n\nif (loggedInUserRankIndex <= existingUserRankIndex) {\n return yield* new RestAPIError({\n error: 'Unauthorized to view user with higher rank',\n });\n}\n```\n\n`getUser` correctly blocks an admin from viewing an owner record. `getUsers` bypasses that boundary for bulk enumeration.\n\n### Sensitive Fields Returned\n\nThe `getUsers` response includes:\n\n- `id`\n- `email`\n- `name`\n- `username`\n- `rank`\n- timestamps and profile URL/avatar fields when present\n\nThis is enough to enumerate all owner accounts and target them for phishing, social engineering, or follow-on attacks against out-of-band workflows.\n\n## PoC\n\n### HTTP PoC\n\nUse any admin-level REST API token:\n\n```bash\ncurl -X GET 'http://localhost:4321/studiocms_api/rest/v1/secure/users?rank=owner' \\\n -H 'Authorization: Bearer <admin-api-token>'\n```\n\nExpected behavior:\n- owner records should be excluded for admin callers, consistent with `getUser`\n\nActual behavior:\n- the response contains owner user objects, including email addresses and user IDs\n\n### Local Validation of the Exact Handler Logic\n\nI validated the filtering logic locally with the same conditions used by `getUsers` and `getUser`.\n\nObserved output:\n\n```json\n{\n \"admin_getUsers_rank_owner\": [\n {\n \"email\": \"owner@example.test\",\n \"id\": \"owner-1\",\n \"name\": \"Site Owner\",\n \"rank\": \"owner\",\n \"username\": \"owner1\"\n }\n ],\n \"admin_getUser_owner\": \"Unauthorized to view user with higher rank\"\n}\n```\n\nThis demonstrates the authorization mismatch clearly:\n- bulk listing with `rank=owner` exposes owner records\n- direct access to a single owner record is denied\n\n## Impact\n\n- **Owner Account Enumeration:** Admin tokens can recover owner user IDs, usernames, display names, and email addresses.\n- **Authorization Boundary Bypass:** The REST collection endpoint bypasses the stricter per-record rank check already implemented by `getUser`.\n- **Chaining Value:** Exposed owner contact data can support phishing, account-targeting, and admin-to-owner pivot attempts in deployments that treat owner identities as higher-trust principals.\n\n## Recommended Fix\n\nApply rank filtering based on the caller's role, not on the request query parameter, and reuse the same privilege rule as `getUser`.\n\nExample fix:\n\n```ts\nconst loggedInUserRankIndex = availablePermissionRanks.indexOf(user.rank);\n\ndata = data.filter((candidate) => {\n const candidateRankIndex = availablePermissionRanks.indexOf(candidate.rank);\n return loggedInUserRankIndex > candidateRankIndex;\n});\n\nif (rank) {\n data = data.filter((candidate) => candidate.rank === rank);\n}\n```\n\nAt minimum, replace:\n\n```ts\nif (rank !== 'owner') {\n data = data.filter((user) => user.rank !== 'owner');\n}\n```\n\nwith a check tied to `user.rank` rather than the query parameter.",
0 commit comments