GHSA-CP4F-5M9R-5JC2

Vulnerability from github – Published: 2026-06-01 14:19 – Updated: 2026-06-01 14:19
VLAI
Summary
praisonai-platform: Comment endpoints accept any issue_id without workspace ownership check, cross-workspace comment read and post IDOR
Details

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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "praisonai-platform"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.1.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-47417"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-639"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-01T14:19:33Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\n**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`.\n**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.\n**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.\n\n## Affected Code\n\n**File 1:** `src/praisonai-platform/praisonai_platform/api/routes/issues.py`, lines 143-171.\n\n```python\n@router.post(\"/{issue_id}/comments\", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)\nasync def add_comment(\n    workspace_id: str,\n    issue_id: str,\n    body: CommentCreate,\n    user: AuthIdentity = Depends(require_workspace_member),         # only checks attacker is in workspace_id\n    session: AsyncSession = Depends(get_db),\n):\n    svc = CommentService(session)\n    comment = await svc.create(\n        issue_id=issue_id,                                          # \u003c-- BUG: no validation that issue_id is in workspace_id\n        author_id=user.id,\n        content=body.content,\n        author_type=\"member\" if user.is_user else \"agent\",\n        parent_id=body.parent_id,\n    )\n    return CommentResponse.model_validate(comment)\n\n\n@router.get(\"/{issue_id}/comments\", response_model=List[CommentResponse])\nasync def list_comments(\n    workspace_id: str,\n    issue_id: str,\n    user: AuthIdentity = Depends(require_workspace_member),\n    session: AsyncSession = Depends(get_db),\n):\n    svc = CommentService(session)\n    comments = await svc.list_for_issue(issue_id)                   # \u003c-- BUG: returns comments on any issue\n    return [CommentResponse.model_validate(c) for c in comments]\n```\n\n**File 2:** `src/praisonai-platform/praisonai_platform/services/comment_service.py`, lines 19-53.\n\n```python\nclass CommentService:\n    ...\n\n    async def create(\n        self,\n        issue_id: str,\n        author_id: str,\n        content: str,\n        author_type: str = \"member\",\n        comment_type: str = \"comment\",\n        parent_id: Optional[str] = None,\n    ) -\u003e Comment:\n        comment = Comment(\n            issue_id=issue_id,                                      # \u003c-- accepts any issue_id; no workspace verify\n            author_type=author_type,\n            author_id=author_id,\n            ...\n        )\n        self._session.add(comment)\n        await self._session.flush()\n        return comment\n\n    async def list_for_issue(self, issue_id: str) -\u003e list[Comment]:\n        stmt = (\n            select(Comment)\n            .where(Comment.issue_id == issue_id)                    # \u003c-- no JOIN against issues for workspace constraint\n            .order_by(Comment.created_at)\n        )\n        result = await self._session.execute(stmt)\n        return list(result.scalars().all())\n```\n\n**Why it\u0027s 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.\n\n## Exploit Chain\n\n1. 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\u0027s own workspace whose internals reference foreign issue IDs (cross-workspace links, search across activity events). State: attacker holds `I_T`.\n2. 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`.\n3. `CommentService.list_for_issue(I_T)` runs `SELECT * FROM comments WHERE issue_id = \u0027I_T\u0027` with no workspace constraint. Every comment on the foreign issue is returned: `content` (often the most sensitive part of an issue tracker \u2014 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.\n4. Attacker repeats with `POST /workspaces/W_attacker/issues/I_T/comments` and a body of `{\"content\": \"\u003cmalicious\u003e\"}`. `CommentService.create(issue_id=I_T, author_id=attacker, ...)` writes a row with the foreign issue\u0027s id and the attacker\u0027s `author_id`. State: a new comment authored by the attacker appears in the foreign workspace\u0027s issue thread, indistinguishable to the foreign workspace\u0027s 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\u0027s users.\n5. 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\u0027s `author_id`, but the foreign workspace has no member with that id and the foreign workspace\u0027s audit logs show no event (the `act_svc.log` call in `add_comment` is omitted).\n\n## Security Impact\n\n**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\u0027s own id), no availability claim.\n**Attacker capability:** read every comment on every issue in the multi-tenant deployment given the issue UUIDs; post arbitrary comments under the attacker\u0027s identity into any foreign issue, allowing comment-spam, phishing-link injection into another tenant\u0027s UI, or social-engineering attribution attacks (the foreign workspace\u0027s UI renders a comment whose author belongs to no member of that workspace).\n**Preconditions:** `praisonai-platform` is deployed multi-tenant; the attacker has any membership token; the target issue\u0027s UUID is known or guessable.\n**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.\n\n## Suggested Fix\n\nResolve 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.\n\n```diff\n--- a/src/praisonai-platform/praisonai_platform/api/routes/issues.py\n+++ b/src/praisonai-platform/praisonai_platform/api/routes/issues.py\n@@ -141,6 +141,11 @@ async def delete_issue(...):\n # \u2500\u2500 Comments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n\n+async def _require_issue_in_workspace(session, workspace_id: str, issue_id: str):\n+    issue = await IssueService(session).get(workspace_id, issue_id)  # workspace-scoped get (see companion advisory)\n+    if issue is None:\n+        raise HTTPException(status_code=404, detail=\"Issue not found\")\n+\n @router.post(\"/{issue_id}/comments\", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)\n async def add_comment(\n     workspace_id: str,\n@@ -149,6 +154,7 @@ async def add_comment(\n     user: AuthIdentity = Depends(require_workspace_member),\n     session: AsyncSession = Depends(get_db),\n ):\n+    await _require_issue_in_workspace(session, workspace_id, issue_id)\n     svc = CommentService(session)\n     comment = await svc.create(\n         issue_id=issue_id,\n@@ -167,5 +173,6 @@ async def list_comments(\n     user: AuthIdentity = Depends(require_workspace_member),\n     session: AsyncSession = Depends(get_db),\n ):\n+    await _require_issue_in_workspace(session, workspace_id, issue_id)\n     svc = CommentService(session)\n     comments = await svc.list_for_issue(issue_id)\n```\n\nCompanion advisories file the same workspace-scoping gap for `AgentService`, `IssueService`, `ProjectService`, and `LabelService`. Each is a separate exploitable IDOR.",
  "id": "GHSA-cp4f-5m9r-5jc2",
  "modified": "2026-06-01T14:19:33Z",
  "published": "2026-06-01T14:19:33Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-cp4f-5m9r-5jc2"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/MervinPraison/PraisonAI"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "praisonai-platform: Comment endpoints accept any issue_id without workspace ownership check, cross-workspace comment read and post IDOR"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…