PT-2026-45066 · Pypi · Praisonai-Platform

Published

2026-05-29

·

Updated

2026-05-29

·

CVE-2026-47416

CVSS v3.1

9.6

Critical

VectorAV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

Summary

Type: Vertical privilege escalation. The PATCH /workspaces/{workspace id}/members/{user id} endpoint is gated by require workspace member(workspace id), which defaults to min role="member" and is never overridden by the route. The handler then calls MemberService.update role(workspace id, user id, body.role) which sets the target member's role to whatever the request body specifies, with no check that the caller has owner-or-admin privilege, no check that the new role is not higher than the caller's own, and no check that the caller is not silently promoting themselves. File: src/praisonai-platform/praisonai platform/api/routes/workspaces.py, lines 115-127; services/member service.py, lines 55-69; api/deps.py, lines 54-73. Root cause: require workspace member exists with a min role parameter (deps.py:58) but FastAPI's Depends(require workspace member) cannot pass arguments, so every route uses the default "member". The route then passes the URL-supplied user id and the body-supplied role directly to MemberService.update role, which contains zero permission checks: it loads the member by composite key and assigns member.role = new role. A user with the lowest possible privilege ("member") thus sets their own role to "owner" with one HTTP PATCH, completing a member-to-owner privilege escalation in a single request.

Affected Code

File 1: src/praisonai-platform/praisonai platform/api/routes/workspaces.py, lines 115-127.
python
@router.patch("/{workspace id}/members/{user id}", response model=MemberResponse)
async def update member role(
  workspace id: str,
  user id: str,
  body: MemberUpdate,
  user: AuthIdentity = Depends(require workspace member),     # <-- BUG: defaults to min role="member"; no role gate
  session: AsyncSession = Depends(get db),
):
  member svc = MemberService(session)
  member = await member svc.update role(workspace id, user id, body.role) # <-- writes any role to any member
  if member is None:
    raise HTTPException(status code=404, detail="Member not found")
  return MemberResponse.model validate(member)
File 2: src/praisonai-platform/praisonai platform/services/member service.py, lines 55-69.
python
async def update role(
  self,
  workspace id: str,
  user id: str,
  new role: str,
) -> Optional[Member]:
  """Update a member's role."""
  if new role not in VALID ROLES:                 # only validates the *value*, not the *caller's right*
    raise ValueError(f"Invalid role: {new role}. Must be one of {VALID ROLES}")
  member = await self.get(workspace id, user id)
  if member is None:
    return None
  member.role = new role                      # <-- BUG: no caller-role check, no target-vs-caller hierarchy check
  await self. session.flush()
  return member
File 3: src/praisonai-platform/praisonai platform/api/deps.py, lines 54-73.
python
async def require workspace member(
  workspace id: str,
  user: AuthIdentity = Depends(get current user),
  session: AsyncSession = Depends(get db),
  min role: str = "member",                    # <-- default that no route overrides
) -> AuthIdentity:
  member svc = MemberService(session)
  has = await member svc.has role(workspace id, user.id, min role)
  if not has:
    raise HTTPException(status code=403, detail="Not a member of this workspace or insufficient role")
  user.workspace id = workspace id
  return user
Why it's wrong: require workspace member was clearly designed to be tunable per-route — the min role parameter is right there — but Depends(require workspace member) in FastAPI cannot pass arguments to a dependency, so every route resolves to the default "member". The author's intent is also evident in MemberService.has role (member service.py:80-96), which implements an owner > admin > member hierarchy that this endpoint should be enforcing. The endpoint uses none of it. The VALID ROLES = {"owner", "admin", "member"} enum check (member service.py:62) only validates the new role string is recognised, not that the caller has the right to assign it. As a result, a member can write {"role": "owner"} to their own membership row and become owner in one PATCH.

Exploit Chain

  1. Attacker registers an account and joins (or is invited to) any workspace W as a "member" (the lowest privilege tier — typically anyone can be added by an owner during onboarding, or self-joins via an invite link). State: attacker has a JWT, is a Member(workspace id=W, user id=attacker, role="member").
  2. Attacker sends PATCH /workspaces/W/members/<attacker user id> with Authorization: Bearer <attacker jwt> and body {"role": "owner"}. State: control flow enters update member role.
  3. require workspace member(W, attacker) runs. Its default min role="member" is satisfied because the attacker is a member. The dependency returns the attacker's identity. State: route handler proceeds with no further role gate.
  4. MemberService.update role(W, attacker, "owner") runs. VALID ROLES accepts "owner". self.get(W, attacker) returns the attacker's existing member row. The next line, member.role = "owner", mutates the attacker's role in place. await self. session.flush() commits. State: attacker is now Member(workspace id=W, user id=attacker, role="owner").
  5. Attacker re-issues GET /auth/me (or any owner-gated endpoint) and is now treated as workspace owner. State: full administrative control of the workspace, including the ability to add/remove members, change settings, delete the workspace, and exfiltrate everything via the agent/issue/project/comment IDORs that were filed as separate advisories.
  6. Final state: starting from the lowest workspace privilege, the attacker holds owner of the workspace within one HTTP request. The same primitive also lets the attacker DEMOTE the legitimate owner by sending PATCH /workspaces/W/members/<owner user id> with {"role": "member"} — owner lockout in two requests total.

Security Impact

Severity: sec-critical. CVSS 9.1: network attack, low complexity, low privileges (the lowest tier on the platform), no user interaction, scope changed (the privilege boundary the attacker crosses is the workspace owner, a different security principal), high confidentiality and integrity (full workspace control), no availability claim (the attacker can also DELETE the workspace via the companion delete workspace advisory, but that is a separate finding). Attacker capability: with one workspace-member token plus one PATCH request, the attacker becomes workspace owner. From there: add/remove any user as owner, change every workspace setting (including the settings JSON blob), demote the legitimate owner to "member", or chain into the companion delete workspace advisory to wipe the workspace entirely. In multi-tenant SaaS deployments where any signup yields a member-level account in some default workspace, this is effectively pre-auth. Preconditions: praisonai-platform is deployed multi-tenant (more than one workspace exists OR the deployment grants member access on signup); the attacker has any membership token in the target workspace. Differential: source-inspection-verified end-to-end. The asymmetry between require workspace member's min role parameter (which exists, defaults to "member", and is never overridden) and MemberService.has role's clearly tiered owner > admin > member hierarchy (which exists but is never invoked with anything but the default) is the smoking gun. With the suggested fix below, the route resolves with min role="owner", the attacker's member-level token fails the gate at the dependency, and the privilege escalation never reaches the service layer.

Suggested Fix

The fix has two parts. First, the route must resolve require workspace member with min role="owner" (or at least "admin"). Second, MemberService.update role should refuse to set a target's role higher than the caller's own role, so that an admin cannot accidentally produce another owner.
diff
--- a/src/praisonai-platform/praisonai platform/api/routes/workspaces.py
+++ b/src/praisonai-platform/praisonai platform/api/routes/workspaces.py
@@ -115,11 +115,16 @@
+def require owner(workspace id: str, user, session):
+  return require workspace member(workspace id, user, session, min role="owner")
+
 @router.patch("/{workspace id}/members/{user id}", response model=MemberResponse)
 async def update member role(
   workspace id: str,
   user id: str,
   body: MemberUpdate,
-  user: AuthIdentity = Depends(require workspace member),
+  user: AuthIdentity = Depends( require owner),
   session: AsyncSession = Depends(get db),
 ):
   member svc = MemberService(session)
+  if not await member svc.has role(workspace id, user.id, "owner"):
+    raise HTTPException(status code=403, detail="Only owners can change member roles")
   member = await member svc.update role(workspace id, user id, body.role)
Defence-in-depth in the service layer:
diff
--- a/src/praisonai-platform/praisonai platform/services/member service.py
+++ b/src/praisonai-platform/praisonai platform/services/member service.py
@@ -55,7 +55,7 @@
-  async def update role(self, workspace id: str, user id: str, new role: str) -> Optional[Member]:
+  async def update role(self, workspace id: str, caller id: str, user id: str, new role: str) -> Optional[Member]:
     """Update a member's role."""
+    if not await self.has role(workspace id, caller id, "owner"):
+      raise PermissionError("Only owners can update member roles")
     if new role not in VALID ROLES:
       raise ValueError(...)
The companion endpoints add member, remove member, delete workspace, and update workspace exhibit the same Depends(require workspace member) default-min-role pattern and are filed as their own advisories so each gets a separate CVE.

Fix

Missing Authorization

Improper Privilege Management

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-47416
GHSA-C2M8-4GCG-V22G

Affected Products

Praisonai-Platform