# praisonai-platform: Issue endpoints accept any issue_id without workspace ownership check, cross-workspace read/update/delete IDOR

PraisonAI issue CRUD endpoints gate only on workspace membership, then look up issue_id by primary key, letting any workspace member read, edit or delete foreign-workspace issues.

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

**Target:** FastAPI issue CRUD routes backed by SQLAlchemy IssueService
**Root cause:** Route handlers use workspace_id only for the require_workspace_member gate, then call IssueService.get/update/delete(issue_id) which performs a primary-key lookup with no workspace_id predicate, so ownership of the addressed issue is never verified.
**Steps:**
1. Register/own a workspace `W_attacker` and obtain a target issue UUID `I_T` from activity feed, comments, error messages or exports.
2. Send `GET /workspaces/W_attacker/issues/I_T` with `Authorization: Bearer <attacker_jwt>`; membership check passes and `IssueService.get(I_T)` returns the foreign issue row.
3. Send `PATCH /workspaces/W_attacker/issues/I_T` with `{"description": "<reset>", "status": "closed", "project_id": "<arbitrary>"}` to mutate the foreign issue.
4. Send `DELETE /workspaces/W_attacker/issues/I_T` to delete the foreign issue.
**How found:** Source review: noticed route uses workspace_id only for the membership dependency and never threads it into IssueService.get; contrasted with MemberService which uses a composite (workspace_id, user_id) key, confirming the safe pattern was known but not applied.
**Exploitation:** Any authenticated user with membership in one workspace harvests a foreign issue UUID via side channels (activity log, comments, error messages, exports), then uses their own workspace_id in the URL path to pass the membership gate while the service resolves the victim issue by primary key, enabling read, arbitrary field overwrite including project_id re-assignment, and deletion. Audit events are logged under the attacker's workspace, hiding tampering from the victim.
**Escalation:** PATCH allows overwriting project_id, re-assigning the foreign issue to an unrelated project; same single-key pattern also affects AgentService, ProjectService, CommentService and LabelService.
**Insight:** When a route has both a parent-scope path param and a child resource id, check whether the service lookup includes the parent predicate; a sibling service using a composite key in the same codebase is a strong signal the single-key lookups elsewhere are IDORs.

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

**GET issue handler with unthreaded workspace_id**

```
@router.get("/{issue_id}", response_model=IssueResponse)
async def get_issue(
    workspace_id: str,
    issue_id: str,
    user: AuthIdentity = Depends(require_workspace_member),         # only checks membership in workspace_id
    session: AsyncSession = Depends(get_db),
):
    svc = IssueService(session)
    issue = await svc.get(issue_id)                                 # <-- workspace_id never threaded through
    if issue is None:
        raise HTTPException(status_code=404, detail="Issue not found")
    return IssueResponse.model_validate(issue)
```

**IssueService.get primary-key lookup without workspace predicate**

```
async def get(self, issue_id: str) -> Optional[Issue]:
        """Get issue by ID."""
        return await self._session.get(Issue, issue_id)
```

**PATCH handler calling svc.update on arbitrary issue_id**

```
@router.patch("/{issue_id}", response_model=IssueResponse)
async def update_issue(
    workspace_id: str,
    issue_id: str,
    body: IssueUpdate,
    user: AuthIdentity = Depends(require_workspace_member),
    session: AsyncSession = Depends(get_db),
):
    svc = IssueService(session)
    issue = await svc.update(                                       # <-- writes to any issue in the DB
        issue_id, title=body.title, description=body.description,
        status=body.status, priority=body.priority,
        assignee_type=body.assignee_type, assignee_id=body.assignee_id,
        project_id=body.project_id,
```

