# praisonai-platform: Any workspace member can promote themselves or others to owner via PATCH /workspaces/{id}/members/{user_id}

Any workspace member could PATCH their own membership row with role=owner in praisonai-platform, escalating to full workspace owner in one request.

- Payout: Research
- Severity: critical
- Program: praisonai-platform
- Found by: offset, 0xEr3n
- Disclosed: 2026-05-29
- CVE: CVE-2026-47416
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-c2m8-4gcg-v22g
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-c2m8-4gcg-v22g
- Tags: area:web domain:api domain:web impact:integrity impact:privesc kind:research platform:ghsa sev:critical tech:python tech:rest technique:source-review vuln:access-control vuln:auth-bypass vuln:privilege-escalation

**Target:** FastAPI REST endpoint PATCH /workspaces/{workspace_id}/members/{user_id}
**Root cause:** Route uses Depends(require_workspace_member) which cannot pass min_role, so it defaults to "member"; MemberService.update_role then writes any body-supplied role to any member with no caller-role or hierarchy check.
**Steps:**
1. Authenticate as a user holding role "member" in workspace W.
2. Send `PATCH /workspaces/W/members/<attacker_user_id>` with `Authorization: Bearer <attacker_jwt>` and body `{"role": "owner"}`.
3. require_workspace_member passes since min_role defaults to "member"; update_role validates only that "owner" is in VALID_ROLES and sets member.role = "owner".
4. Call `GET /auth/me` or any owner-gated endpoint to confirm owner privileges.
5. Optionally demote the real owner via `PATCH /workspaces/W/members/<owner_user_id>` with `{"role": "member"}`.
**How found:** Source review: noticed require_workspace_member exposes a min_role parameter that no route overrides (FastAPI Depends can't pass args), while MemberService.has_role implements an owner>admin>member hierarchy that update_role never invokes — the asymmetry between the intended gate and the actual call was the tell.
**Exploitation:** Single authenticated PATCH request from a lowest-privilege member token rewrites their own membership row to owner; no user interaction, no capital, no chaining required. Same primitive demotes the legitimate owner, enabling lockout.
**Escalation:** Owner control enables add/remove members, settings modification, and chaining into companion delete_workspace/IDOR advisories for full workspace takeover and data exfiltration.
**Insight:** In FastAPI codebases, grep for dependency functions with tunable parameters like min_role/required_role: if routes use bare Depends(fn) without a wrapper, the default (often lowest privilege) silently applies everywhere — then check whether the service layer re-enforces the hierarchy.

## Proof of concept (copied exactly from the original report)

**PATCH member role route with member-only gate**

```
@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)
```

**update_role service with no caller check**

```
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
```

**require_workspace_member default min_role dependency**

```
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
```

