Summary
An authenticated user with the Role Administrator right (rol_assign_roles) in one organization can delete, activate, deactivate, or edit (via the save handler) a role belonging to another organization on the same Admidio install, by supplying that role's UUID to groups_roles.php. The module-level gate checks only whether the actor is a role administrator in their current organization; the delete, activate, deactivate, and save handlers never recheck that the resolved role actually belongs to that organization.
Details
Faulty code: modules/groups-roles/groups_roles.php:50-54
if ($getMode !== 'cards') {
if (!$gCurrentUser->isAdministratorRoles()) {
throw new Exception('SYS_NO_RIGHTS');
}
}
isAdministratorRoles() calls checkRolesRight('rol_assign_roles'), whose query is bound to $this->organizationId (src/Users/Entity/User.php:247: WHERE ... cat_org_id = ? OR cat_org_id IS NULL) — it proves the caller administers roles in their current organization, nothing about the organization the target role belongs to.
delete, activate, and deactivate (groups_roles.php:151-186) resolve the target purely from role_uuid — a globally-unique identifier with no organization scoping — and act on it directly: $role->readDataByUuid($getRoleUUID) followed by $role->delete() / $role->activate() / $role->deactivate(). Role::delete() and the private Role::toggleValid() (src/Roles/Entity/Role.php:234, :847) only special-case default-registration, system, and administrator roles — no comparison against $gCurrentOrgId exists on this path.
save (groups_roles.php:144-149) has the same gap one level down: new RolesService($gDb, $getRoleUUID) loads the role by UUID in its constructor, and RolesService::save() (src/Roles/Service/RolesService.php:228) validates the posted CSRF/form token and writes the submitted values into that role — again with no organization recheck. Note mode=edit (rendering the edit form, GroupsRolesPresenter::createEditForm()) does check cat_org_id against $gCurrentOrgId and throws SYS_NO_RIGHTS on mismatch — the missing check is specifically on the save submission path, not on loading the form.
The missing per-record organization recheck after UUID resolution is the same general defect pattern the project has fixed elsewhere — e.g. modules/categories.php:52-67, patched for CVE-2026-47227 (a cross-module authorization bypass, not cross-organization) — but that recheck was never ported to groups_roles.php's mutating handlers.
PoC
- As a user who is a Role Administrator in Organization A only, obtain a role UUID belonging to Organization B (see precondition above — not obtainable from Organization A's own role list).
- While authenticated in the Organization A session, submit:
POST /modules/groups-roles/groups_roles.php?mode=delete&role_uuid=<ORG_B_ROLE_UUID> with a valid CSRF token from that session.
- The response is
{"status":"success"} and the role is gone from Organization B — along with its memberships, role-rights assignments, and role dependencies — despite the acting user holding no right in Organization B.
- The same request shape with
mode=activate or mode=deactivate toggles the target organization's role instead of deleting it; mode=save (with the role's edit form fields) rewrites its properties.
DB-level verification: querying adm_roles for the target rol_uuid immediately after step 2 returns zero rows, confirming the delete executed against the real row rather than failing silently.
Impact
- Severity: High — integrity and availability impact on a victim organization's role/permission structure; no confidentiality component (attacker destroys/alters, doesn't read).
- Who's affected: any authenticated user holding the Role Administrator right in one organization, against any other organization on the same install, given a target role UUID.
- Silent: the deleted/deactivated role disappears or stops granting access for the victim organization's members with no error and no cross-org-specific audit signal.
- Blast radius: a single request against one role can strip access from every member holding that role in the victim organization.
Suggested Fix
Port the per-record organization recheck already used in GroupsRolesPresenter::createEditForm() (cat_org_id vs $gCurrentOrgId, throwing SYS_NO_RIGHTS on mismatch) into the delete, activate, deactivate handlers in groups_roles.php and into RolesService::save() before it writes the posted values.
Summary
An authenticated user with the Role Administrator right (
rol_assign_roles) in one organization can delete, activate, deactivate, or edit (via thesavehandler) a role belonging to another organization on the same Admidio install, by supplying that role's UUID togroups_roles.php. The module-level gate checks only whether the actor is a role administrator in their current organization; thedelete,activate,deactivate, andsavehandlers never recheck that the resolved role actually belongs to that organization.Details
Faulty code:
modules/groups-roles/groups_roles.php:50-54isAdministratorRoles()callscheckRolesRight('rol_assign_roles'), whose query is bound to$this->organizationId(src/Users/Entity/User.php:247:WHERE ... cat_org_id = ? OR cat_org_id IS NULL) — it proves the caller administers roles in their current organization, nothing about the organization the target role belongs to.delete,activate, anddeactivate(groups_roles.php:151-186) resolve the target purely fromrole_uuid— a globally-unique identifier with no organization scoping — and act on it directly:$role->readDataByUuid($getRoleUUID)followed by$role->delete()/$role->activate()/$role->deactivate().Role::delete()and the privateRole::toggleValid()(src/Roles/Entity/Role.php:234,:847) only special-case default-registration, system, and administrator roles — no comparison against$gCurrentOrgIdexists on this path.save(groups_roles.php:144-149) has the same gap one level down:new RolesService($gDb, $getRoleUUID)loads the role by UUID in its constructor, andRolesService::save()(src/Roles/Service/RolesService.php:228) validates the posted CSRF/form token and writes the submitted values into that role — again with no organization recheck. Notemode=edit(rendering the edit form,GroupsRolesPresenter::createEditForm()) does checkcat_org_idagainst$gCurrentOrgIdand throwsSYS_NO_RIGHTSon mismatch — the missing check is specifically on thesavesubmission path, not on loading the form.The missing per-record organization recheck after UUID resolution is the same general defect pattern the project has fixed elsewhere — e.g.
modules/categories.php:52-67, patched for CVE-2026-47227 (a cross-module authorization bypass, not cross-organization) — but that recheck was never ported togroups_roles.php's mutating handlers.PoC
POST /modules/groups-roles/groups_roles.php?mode=delete&role_uuid=<ORG_B_ROLE_UUID>with a valid CSRF token from that session.{"status":"success"}and the role is gone from Organization B — along with its memberships, role-rights assignments, and role dependencies — despite the acting user holding no right in Organization B.mode=activateormode=deactivatetoggles the target organization's role instead of deleting it;mode=save(with the role's edit form fields) rewrites its properties.DB-level verification: querying
adm_rolesfor the targetrol_uuidimmediately after step 2 returns zero rows, confirming the delete executed against the real row rather than failing silently.Impact
Suggested Fix
Port the per-record organization recheck already used in
GroupsRolesPresenter::createEditForm()(cat_org_idvs$gCurrentOrgId, throwingSYS_NO_RIGHTSon mismatch) into thedelete,activate,deactivatehandlers ingroups_roles.phpand intoRolesService::save()before it writes the posted values.