Type: Insecure Direct Object Reference. The project CRUD endpoints (GET / PATCH / DELETE /workspaces/{workspace_id}/projects/{project_id} and GET .../{project_id}/stats) gate access on require_workspace_member(workspace_id) only, then resolve project_id through ProjectService.get(project_id) / update(project_id, ...) / delete(project_id) / get_stats(project_id). None of these calls thread workspace_id through to constrain the lookup. A user who is a member of any workspace W1 can read, modify, delete, or read stats for projects that belong to a different workspace W2.
File: src/praisonai-platform/praisonai_platform/services/project_service.py, lines 47-108; route handlers at src/praisonai-platform/praisonai_platform/api/routes/projects.py, lines 51-108.
Root cause: identical to the agent and issue IDORs in this codebase. The route accepts workspace_id from URL, uses it solely for the membership gate, then calls ProjectService.get(project_id) which is session.get(Project, project_id) — a primary-key-only lookup with no workspace_id predicate. update and delete call self.get(project_id) first, inheriting the gap. get_stats likewise has no workspace check.
File 1: src/praisonai-platform/praisonai_platform/services/project_service.py, lines 47-108.
class ProjectService:
...
async def get(self, project_id: str) -> Optional[Project]:
"""Get project by ID."""
return await self._session.get(Project, project_id) # <-- BUG: no workspace_id predicate
async def update(
self,
project_id: str,
...
) -> Optional[Project]:
project = await self.get(project_id) # <-- inherits the gap
...
async def delete(self, project_id: str) -> bool:
project = await self.get(project_id) # <-- inherits the gap
...
async def get_stats(self,...
0.1.00.1.10.1.20.1.30.1.4Exploitability
AV:NAC:LPR:LUI:NScope
S:UImpact
C:HI:HA:NCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N