praisonai-platform: Agent endpoints accept any agent_id without workspace ownership check, cross-workspace read/update/delete IDOR
Summary
Type: Insecure Direct Object Reference. The agent CRUD endpoints (GET / PATCH / DELETE /workspaces/{workspace_id}/agents/{agent_id}) gate access on require_workspace_member(workspace_id) only, then resolve agent_id through AgentService.get(agent_id) which is a primary-key lookup with no workspace constraint. A user who is a member of any workspace W1 can read, modify, or delete agents that belong to a different workspace W2 by guessing or harvesting an agent UUID and calling …/workspaces/W1/agents/.
File: src/praisonai-platform/praisonai_platform/services/agent_service.py, lines 53-112; route handlers at src/praisonai-platform/praisonai_platform/api/routes/agents.py, lines 53-100.
Root cause: the route extracts workspace_id from the URL path and passes it to require_workspace_member for the membership check, but never threads it through to the service layer. AgentService.get calls session.get(Agent, agent_id), which is SELECT * FROM agents WHERE id = :agent_id with no AND workspace_id = :workspace_id. update and delete call self.get(agent_id) first and then mutate the returned row, inheriting the same gap. The MemberService is the one place in this codebase that does this correctly: it uses (workspace_id, user_id) as a composite key. The agent service simply forgot the second predicate, which is the textbook GHSA pattern for FastAPI services that treat routing parameters as decorative rather than authoritative.
Affected Code
File 1: src/praisonai-platform/praisonai_platform/services/agent_service.py, lines 53-55 and 105-112.
class AgentService:
...
async def get(self, agent_id: str) -> Optional[Agent]:
"""Get agent by ID."""
return await self._session.get(Agent, agent_id) # Optional[Agent]:
agent = await self.get(agent_id) # bool:
agent = await self.get(agent_id) #