PT-2026-45488 · Pypi · Praisonai-Platform

Published

2026-06-01

·

Updated

2026-06-01

·

CVE-2026-47417

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 comment endpoints (POST /workspaces/{workspace id}/issues/{issue id}/comments and GET .../comments) gate access on require workspace member(workspace id) only, then call CommentService.create(issue id=issue id, ...) and CommentService.list for issue(issue id) without verifying that issue id belongs to workspace id. A user who is a member of any workspace W1 can read every comment on, and post new comments to, any issue in any other workspace W2. File: src/praisonai-platform/praisonai platform/api/routes/issues.py, lines 143-171; src/praisonai-platform/praisonai platform/services/comment service.py, lines 19-53. Root cause: the route extracts workspace id from the URL path and uses it solely for the membership gate, then passes the URL-supplied issue id straight into CommentService without confirming that this issue exists in workspace id. CommentService.list for issue(issue id) runs SELECT * FROM comments WHERE issue id = :issue id with no workspace join. CommentService.create(issue id=issue id, ...) blindly writes a row with that issue id. Both flows trust the URL-supplied issue ID as authoritative even though the membership check guarantees nothing about it.

Affected Code

File 1: src/praisonai-platform/praisonai platform/api/routes/issues.py, lines 143-171.
@router.post("/{issue id}/comments", response model=CommentResponse, status code=status.HTTP 201 CREATED)
async def add comment(
  workspace id: str,
  issue id: str,
  body: CommentCreate,
  user: AuthIdentity = Depends(require workspace member),     # only checks attacker is in workspace id
  session: AsyncSession = Depends(get db),
):
  svc = CommentService(session)
  comment = await svc.create(
    issue id=issue id,                     # <-- BUG: no validation that issue id is in workspace id
    author id=user.id,
    content=body.content,
    author type="member" if user.is user else "agent",
    parent id=body.parent id,
  )
  return CommentResponse.model validate(comment)


@router.get("/{issue id}/comments", response model=List[CommentResponse])
async def list comments(
  workspace id: str,
  issue id: str,
  user: AuthIdentity = Depends(require workspace member),
  session: AsyncSession = Depends(get db),
):
  svc = CommentService(session)
  comments = await svc.list for issue(issue id)          # <-- BUG: returns comments on any issue
  return [CommentResponse.model validate(c) for c in comments]
File 2: src/praisonai-platform/praisonai platform/services/comment service.py, lines 19-53.
class CommentService:
  ...

  async def create(
    self,
    issue id: str,
    author id: str,
    content: str,
    author type: str = "member",
    comment type: str = "comment",
    parent id: Optional[str] = None,
  ) -> Comment:
    comment = Comment(
      issue id=issue id,                   # <-- accepts any issue id; no workspace verify
      author type=author type,
      author id=author id,
      ...
    )
    self. session.add(comment)
    await self. session.flush()
    return comment

  async def list for issue(self, issue id: str) -> list[Comment]:
    stmt = (
      select(Comment)
      .where(Comment.issue id == issue id)          # <-- no JOIN against issues for workspace constraint
      .order by(Comment.created at)
    )
    result = await self. session.execute(stmt)
    return list(result.scalars().all())
Why it's wrong: the service trusts the caller-supplied issue id as authoritative, but the route layer never verified that this issue belongs to the workspace the membership check covers. The standard FastAPI/SQLAlchemy fix is to first resolve the issue scoped to workspace id (Issue.id = :issue id AND Issue.workspace id = :workspace id) and only then proceed to comment operations. The MemberService.get(workspace id, user id) and LabelService.list for workspace(workspace id) calls in the same codebase show the safe predicate; the comment service forgot to apply it.

Exploit Chain

  1. Attacker registers a workspace W attacker (member) and harvests a target issue UUID I T from any side channel: agent prompts that mention issues, the activity feed (act svc.log records issue id), webhook payloads, exported issue dumps, or simply by being a low-privilege observer of the attacker's own workspace whose internals reference foreign issue IDs (cross-workspace links, search across activity events). State: attacker holds I T.
  2. Attacker authenticates and sends GET /workspaces/W attacker/issues/I T/comments. require workspace member(W attacker, attacker) passes (attacker is a member of W attacker). State: control flow enters list comments with workspace id=W attacker, issue id=I T.
  3. CommentService.list for issue(I T) runs SELECT * FROM comments WHERE issue id = 'I T' with no workspace constraint. Every comment on the foreign issue is returned: content (often the most sensitive part of an issue tracker — bug-report repro steps with secrets, customer PII, internal triage notes), author id, author type, parent id, created at. State: response body is the full comment thread of the foreign issue.
  4. Attacker repeats with POST /workspaces/W attacker/issues/I T/comments and a body of {"content": "<malicious>"}. CommentService.create(issue id=I T, author id=attacker, ...) writes a row with the foreign issue's id and the attacker's author id. State: a new comment authored by the attacker appears in the foreign workspace's issue thread, indistinguishable to the foreign workspace's UI from a legitimate cross-workspace mention. Used at scale this becomes a comment-spam / phishing primitive (links in the comment body) targeting another tenant's users.
  5. Final state: any attacker with one workspace-member token can exfiltrate every comment in the multi-tenant deployment given the issue UUIDs, and inject arbitrary comments under their own author identity into any foreign issue. The cross-workspace attribution gap is the worst part: the comment is recorded with the attacker's author id, but the foreign workspace has no member with that id and the foreign workspace's audit logs show no event (the act svc.log call in add comment is omitted).

Security Impact

Severity: sec-high. CVSS 7.6: network attack, low complexity, low privileges, no user interaction, scope unchanged, high confidentiality (full comment threads), high integrity (cross-workspace comment injection under attacker's own id), no availability claim. Attacker capability: read every comment on every issue in the multi-tenant deployment given the issue UUIDs; post arbitrary comments under the attacker's identity into any foreign issue, allowing comment-spam, phishing-link injection into another tenant's UI, or social-engineering attribution attacks (the foreign workspace's UI renders a comment whose author belongs to no member of that workspace). Preconditions: praisonai-platform is deployed multi-tenant; the attacker has any membership token; the target issue's UUID is known or guessable. Differential: source-inspection-verified end-to-end. The asymmetry between CommentService.list for issue(issue id) (no workspace predicate) and LabelService.list for workspace(workspace id) (correctly workspace-scoped) confirms the gap. With the suggested fix below, every comment route first resolves the issue scoped to workspace id, returns 404 if the issue is foreign, and only then proceeds.

Suggested Fix

Resolve the issue scoped to workspace id at the route layer before dispatching to CommentService. This both fixes the read and the write paths and avoids changing the CommentService signature.
--- a/src/praisonai-platform/praisonai platform/api/routes/issues.py
+++ b/src/praisonai-platform/praisonai platform/api/routes/issues.py
@@ -141,6 +141,11 @@ async def delete issue(...):
 # ── Comments ─────────────────────────────────────────────────────────────────


+async def require issue in workspace(session, workspace id: str, issue id: str):
+  issue = await IssueService(session).get(workspace id, issue id) # workspace-scoped get (see companion advisory)
+  if issue is None:
+    raise HTTPException(status code=404, detail="Issue not found")
+
 @router.post("/{issue id}/comments", response model=CommentResponse, status code=status.HTTP 201 CREATED)
 async def add comment(
   workspace id: str,
@@ -149,6 +154,7 @@ async def add comment(
   user: AuthIdentity = Depends(require workspace member),
   session: AsyncSession = Depends(get db),
 ):
+  await require issue in workspace(session, workspace id, issue id)
   svc = CommentService(session)
   comment = await svc.create(
     issue id=issue id,
@@ -167,5 +173,6 @@ async def list comments(
   user: AuthIdentity = Depends(require workspace member),
   session: AsyncSession = Depends(get db),
 ):
+  await require issue in workspace(session, workspace id, issue id)
   svc = CommentService(session)
   comments = await svc.list for issue(issue id)
Companion advisories file the same workspace-scoping gap for AgentService, IssueService, ProjectService, and LabelService. Each is a separate exploitable IDOR.

Fix

IDOR

Weakness Enumeration

Related Identifiers

CVE-2026-47417
GHSA-CP4F-5M9R-5JC2

Affected Products

Praisonai-Platform