# praisonai-platform: Comment endpoints accept any issue_id without workspace ownership check, cross-workspace comment read and post IDOR

Comment endpoints in praisonai-platform gate only on workspace membership, then trust URL issue_id, letting any workspace member read and post comments on foreign workspaces' issues.

- Payout: Research
- Severity: high
- Program: praisonai-platform
- Found by: offset, 0xEr3n
- Disclosed: 2026-06-01
- CVE: CVE-2026-47417
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-cp4f-5m9r-5jc2
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cp4f-5m9r-5jc2
- Tags: area:web domain:api domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:python tech:rest technique:source-review vuln:access-control vuln:idor

**Target:** FastAPI issue comment REST endpoints (GET/POST /workspaces/{workspace_id}/issues/{issue_id}/comments)
**Root cause:** Route uses URL workspace_id only for the require_workspace_member gate, then passes the URL-supplied issue_id into CommentService without verifying the issue belongs to that workspace; list_for_issue queries comments by issue_id with no workspace join.
**Steps:**
1. Register/own a workspace W_attacker and obtain a target foreign issue UUID I_T via side channels (activity feed, webhook payloads, exports).
2. Send `GET /workspaces/W_attacker/issues/I_T/comments` with a valid member token; membership check passes on W_attacker.
3. `CommentService.list_for_issue(I_T)` runs `SELECT * FROM comments WHERE issue_id = 'I_T'` with no workspace constraint, returning the foreign thread.
4. Send `POST /workspaces/W_attacker/issues/I_T/comments` with `{"content": "<malicious>"}` to write a comment row onto the foreign issue under the attacker's author_id.
**How found:** Source review: noticed CommentService.list_for_issue takes only issue_id with no workspace predicate, contrasting with correctly scoped LabelService.list_for_workspace(workspace_id) and MemberService.get(workspace_id, user_id) in the same codebase.
**Exploitation:** Any user with a membership token in one workspace can, given a foreign issue UUID, read the full comment thread (repro steps, PII, triage notes) and inject comments into another tenant's issue, enabling spam/phishing links rendered in the victim workspace UI with no audit event logged.
**Escalation:** At scale, enumerating issue UUIDs across the multi-tenant deployment yields mass comment exfiltration and cross-tenant comment injection; companion advisories show the same gap in AgentService, IssueService, ProjectService, LabelService.
**Insight:** When a route has both a workspace_id path param and a nested resource id, check whether the service layer re-scopes the nested id; grep for sibling services that correctly take workspace_id as the safe-predicate baseline and diff against the suspect one.

## Proof of concept (copied exactly from the original report)

**Vulnerable comment route handlers accepting any issue_id**

```
@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]
```

**CommentService create/list without workspace scoping**

```
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())
```

**Exploit requests for cross-workspace comment read/post**

```
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>"}`
```

