| Gói / sản phẩm | Phiên bản bị ảnh hưởng | Phiên bản đã vá |
|---|---|---|
| praisonai-platform | < 0.1.4 | 0.1.4 |
Type: Authorization bypass enabling owner lockout. The DELETE /workspaces/{workspace_id}/members/{user_id} endpoint is gated only by require_workspace_member(workspace_id) (default min_role="member"). Any member can remove any other member, including the workspace owner, using a single DELETE. There is no caller-role check, no target-role check, no "cannot remove last owner" guard.
File: src/praisonai-platform/praisonai_platform/api/routes/workspaces.py, lines 130-140; services/member_service.py, lines 71-78.
Root cause: MemberService.remove(workspace_id, user_id) performs the deletion without any caller-permission check or owner-protection logic. The route accepts the URL-supplied user_id and dispatches it straight through. The role hierarchy (MemberService.has_role) is implemented but never invoked here. A member-tier attacker can issue DELETE .../members/<owner_user_id> and immediately lock the legitimate owner out of the workspace.
File 1: src/praisonai-platform/praisonai_platform/api/routes/workspaces.py, lines 130-140.
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
user_id: str,
user: AuthIdentity = Depends(require_workspace_member), # <-- BUG: defaults to min_role="member"
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
removed = await member_svc.remove(workspace_id, user_id) # <-- removes any member, including owner
if not removed:
raise HTTPException(status_code=404, detail="Member not found")
File 2: src/praisonai-platform/praisonai_platform/services/member_service.py, lines 71-78.
async def remove(self, workspace_id: str, user_id: str) -> bool:
"""Remove a member from a workspace."""
member = await self.get(workspace_id, user_id)
if member is None:
return False
await self._session.delete(member) # <-- BUG: no caller-role check, no last-owner protection
await self._session.flush()
return True
Why it's wrong: member-removal is the textbook capability that must be gated on owner role. Removing the workspace owner is a permanent denial-of-service against the legitimate owner unless another owner exists. There must be (a) a caller min-role gate of "owner" or "admin", (b) a check that prevents removing a member whose role is higher than the caller's, and (c) a check that the workspace is left with at least one owner. None of these exist.
Bạn đang dùng một trong các sản phẩm này? Quét site để biết có đang lộ phiên bản có lỗ hổng không.
Quét site miễn phíW with role "member". State: attacker holds JWT.user_id via GET /workspaces/W/members (list_members has the same default-member gate, separate finding). Owner UUID O_id is now known. State: attacker holds O_id.DELETE /workspaces/W/members/O_id with Authorization: Bearer <attacker_jwt>. State: control flow enters remove_member.require_workspace_member(W, attacker) passes (attacker is a member). MemberService.remove(W, O_id) deletes the owner's member row. State: Member(workspace_id=W, user_id=O_id, role="owner") is gone.GET /workspaces/W/... and require_workspace_member(W, O_id) returns 403. State: legitimate owner is now locked out of their own workspace.update_member_role companion advisory, the attacker first promotes themselves to owner, then removes the legitimate owner, then has uncontested control. Combined with delete_workspace, the attacker wipes the workspace after kicking the owner.Severity: sec-high. CVSS 8.1: network attack, low complexity, low privileges, no user interaction, scope unchanged, no confidentiality, high integrity (membership table corrupted), high availability (legitimate owner cannot access their own workspace).
Attacker capability: with one workspace-member token plus one DELETE request, the attacker permanently locks any other member (including the workspace owner) out of the workspace.
Preconditions: praisonai-platform is deployed multi-tenant; attacker has any membership token; owner's user_id is reachable via the (unauthenticated-for-member) list_members endpoint.
Differential: source-inspection-verified. The asymmetry between require_workspace_member's tunable min_role parameter and this endpoint's use of the default value confirms the gap. With the suggested fix below, member-tier tokens fail the gate, and removing the workspace's last owner triggers the additional guard.
--- a/src/praisonai-platform/praisonai_platform/api/routes/workspaces.py
+++ b/src/praisonai-platform/praisonai_platform/api/routes/workspaces.py
@@ -130,11 +130,21 @@
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
user_id: str,
- user: AuthIdentity = Depends(require_workspace_member),
+ user: AuthIdentity = Depends(_require_workspace_owner),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
+ target = await member_svc.get(workspace_id, user_id)
+ if target is not None and target.role == "owner":
+ # Refuse to remove the last owner.
+ owners = [m for m in await member_svc.list_members(workspace_id) if m.role == "owner"]
+ if len(owners) <= 1:
+ raise HTTPException(status_code=409, detail="Cannot remove the last workspace owner")
removed = await member_svc.remove(workspace_id, user_id)
if not removed:
raise HTTPException(status_code=404, detail="Member not found")
The four companion workspace-mutation endpoints exhibit the same default-min-role gap and are filed as their own advisories.