PT-2026-45489 · Pypi · Praisonai-Platform

Published

2026-06-01

·

Updated

2026-06-01

·

CVE-2026-47418

CVSS v3.1

8.1

High

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

Summary

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.

Affected Code

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, project id: str) -> dict:
    ...                             # <-- also no workspace check; returns issue counts for any project
File 2: src/praisonai-platform/praisonai platform/api/routes/projects.py, lines 51-108.
@router.get("/{project id}", response model=ProjectResponse)
async def get project(
  workspace id: str,
  project id: str,
  user: AuthIdentity = Depends(require workspace member),
  session: AsyncSession = Depends(get db),
):
  svc = ProjectService(session)
  project = await svc.get(project id)               # <-- workspace id never threaded through
  if project is None:
    raise HTTPException(status code=404, detail="Project not found")
  return ProjectResponse.model validate(project)


@router.patch("/{project id}", response model=ProjectResponse)
async def update project(...):
  svc = ProjectService(session)
  project = await svc.update(project id, title=body.title, ...)  # <-- writes to any project in the DB

@router.delete("/{project id}", ...)
async def delete project(...):
  deleted = await svc.delete(project id)             # <-- deletes any project in the DB

@router.get("/{project id}/stats")
async def project stats(...):
  return await svc.get stats(project id)             # <-- returns stats for any project in the DB
Why it's wrong: workspace id from the route is treated as a UI hint (gates "are you in some workspace W?") rather than an authoritative predicate (should also gate "is the project you are addressing actually inside W?"). The MemberService in this same codebase uses a composite (workspace id, user id) key and demonstrates the safe pattern; the project service simply did not apply it.

Exploit Chain

  1. Attacker registers a workspace W attacker (where they are a member) and harvests a target project UUID P T. Project IDs leak through the activity feed (act svc.log records entity id), issue records (every issue carries project id), webhook payloads, error messages, exported issue dumps, or operator screenshots. State: attacker holds P T.
  2. Attacker authenticates and sends GET /workspaces/W attacker/projects/P T. require workspace member(W attacker, attacker) passes. State: control flow enters get project with workspace id=W attacker, project id=P T.
  3. ProjectService.get(P T) runs session.get(Project, "P T"), which is SELECT * FROM projects WHERE id = 'P T' LIMIT 1 with no workspace id filter. The row is returned: title, description (often the project's confidential roadmap), status, lead type, lead id, icon, created at, workspace id (the foreign workspace's UUID is itself disclosed). State: response body is the JSON-serialised foreign project.
  4. Attacker repeats with PATCH /workspaces/W attacker/projects/P T and {"title": "<reset>", "description": "<wiped>", "status": "archived"}. update project calls svc.update(P T, ...) and mutates the foreign row. State: target project is silently re-titled, re-described, and archived.
  5. Attacker calls DELETE /workspaces/W attacker/projects/P T to delete the foreign project entirely. State: target project is gone (every issue still referencing it now has a dangling project id).
  6. Attacker calls GET /workspaces/W attacker/projects/P T/stats to read aggregate issue counts (open/closed/in-progress) for the foreign project — useful for competitive intelligence even when full-issue read is not possible.
  7. Final state: any attacker with one workspace-member token can enumerate, exfiltrate, rewrite, and delete every project in the multi-tenant deployment given the project UUIDs.

Security Impact

Severity: sec-high. CVSS: network attack, low complexity, low privileges, no user interaction, scope unchanged, high confidentiality (project content + cross-workspace metadata via the leaked workspace id field), high integrity (arbitrary writes / deletes), no availability claim (issue rows survive parent-project deletion). Attacker capability: read, edit, archive, delete, and stats-fingerprint any project in the multi-tenant deployment given the project UUID. Beyond plain content disclosure, the response also includes workspace id, allowing the attacker to map the deployment's workspace topology (which workspaces exist, which projects each owns). Preconditions: praisonai-platform is deployed multi-tenant; the attacker has any membership token; the target project's UUID is known or guessable. Differential: source-inspection-verified end-to-end. The asymmetry between ProjectService.get(project id) (no workspace check) and MemberService.get(workspace id, user id) (composite key check) confirms the gap. With the suggested fix below, ProjectService.get(workspace id, project id) returns None for foreign-workspace projects and the route handler returns 404.

Suggested Fix

Same shape as the companion agent and issue advisories. Make the resource-lookup query include the workspace predicate; treat foreign-workspace rows as 404.
--- a/src/praisonai-platform/praisonai platform/services/project service.py
+++ b/src/praisonai-platform/praisonai platform/services/project service.py
@@ -45,9 +45,12 @@ class ProjectService:
     await self. session.flush()
     return project

-  async def get(self, project id: str) -> Optional[Project]:
-    """Get project by ID."""
-    return await self. session.get(Project, project id)
+  async def get(self, workspace id: str, project id: str) -> Optional[Project]:
+    """Get project by ID, scoped to a workspace."""
+    stmt = select(Project).where(
+      Project.id == project id, Project.workspace id == workspace id
+    )
+    return (await self. session.execute(stmt)).scalar one or none()

   async def update(
     self,
+    workspace id: str,
     project id: str,
     ...
   ) -> Optional[Project]:
-    project = await self.get(project id)
+    project = await self.get(workspace id, project id)

-  async def delete(self, project id: str) -> bool:
+  async def delete(self, workspace id: str, project id: str) -> bool:
-    project = await self.get(project id)
+    project = await self.get(workspace id, project id)

-  async def get stats(self, project id: str) -> dict:
+  async def get stats(self, workspace id: str, project id: str) -> dict:
+    # Also constrain the underlying issue counts query by workspace id.
Update the route handlers in routes/projects.py to thread workspace id through every call. The same single-key-lookup pattern is filed separately for AgentService, IssueService, CommentService, and LabelService.

Fix

IDOR

Weakness Enumeration

Related Identifiers

CVE-2026-47418
GHSA-943M-6WX2-RC2J

Affected Products

Praisonai-Platform