GHSA-2fjj-qqg8-fg7x
praisonai-platform: Authorization Bypass Through User-Controlled Key
Details
## Summary
The issue create and update endpoints in `praisonai-platform` accept a `project_id` in the request body and persist it without validating that the project belongs to the URL workspace. A user who is a member of workspace `W_B` (and has no access to workspace `W_A`) can create issues that reference a project owned by `W_A`. Because `ProjectService.get_stats()` aggregates issues by `project_id` with no workspace constraint, those foreign issues are then counted in the victim's own legitimate view of their project statistics. This is a cross-tenant integrity violation reachable by an outsider.
This is distinct from the path-parameter IDOR family fixed in 0.1.4 (CVE-2026-47415, CVE-2026-47418, CVE-2026-47419). Those fixes scoped object references supplied in the URL path. This report concerns an object reference supplied in the request body at write time, which the 0.1.4 fixes did not cover.
Version 0.1.4 fixed a set of path-parameter IDORs by threading `workspace_id` into the service-layer lookups (`get` / `update` / `delete`) and by adding the helpers `ensure_resource_in_workspace()` and `require_issue_in_workspace()` in `api/deps.py`. Those helpers are applied to object references that arrive in the URL path. They are not applied to object references that arrive in the request body on create or update.
## Details
`api/routes/issues.py`, `create_issue` passes the body's `project_id` straight through with no workspace validation:
```python @router.post("/", response_model=IssueResponse, status_code=201) async def create_issue(workspace_id: str, body: IssueCreate, user=Depends(require_workspace_member), session=Depends(get_db)): svc = IssueService(session) issue = await svc.create( workspace_id=workspace_id, title=body.title, creator_id=user.id, project_id=body.project_id, # attacker-controlled, not validated against workspace_id ... ) ```
`services/issue_service.py`, `create` persists it as-is:
```python issue = Issue( workspace_id=workspace_id, project_id=project_id, # no check that project_id belongs to workspace_id ... ) ```
`services/issue_service.py`, `update` has the identical gap on the update path:
```python if project_id is not None: issue.project_id = project_id # re-parent to any project, no workspace check ```
`services/project_service.py`, `get_stats` aggregates by `project_id` only:
```python async def get_stats(self, project_id: str) -> dict: stmt = ( select(Issue.status, func.count(Issue.id)) .where(Issue.project_id == project_id) # no workspace_id constraint .group_by(Issue.status) ) ... ```
Note that the read path is not directly vulnerable. The stats route scopes the project first, so a cross-workspace stats read returns 404:
```python @router.get("/{project_id}/stats") async def project_stats(workspace_id, project_id, user=Depends(require_workspace_member), ...): project = await svc.get(project_id, workspace_id=workspace_id) # 404 for a foreign project if project is None: raise HTTPException(404, "Project not found") return await svc.get_stats(project_id) ```
The pollution therefore enters through the write side (issue create/update accepting a foreign `project_id`) and surfaces in the victim's own legitimate read of their project statistics.
## Proof of concept
Two unrelated users:
- Alice, member of workspace `W_A`, owns project `P_A`. - Bob, member of workspace `W_B` only. Bob has no access to `W_A` (every direct call to `W_A` resources returns 403).
Steps:
1. Alice's project `P_A` has one in-progress issue. `GET /workspaces/W_A/projects/P_A/stats` returns `{"total": 1, "by_status": {"in_progress": 1}}`.
2. Bob creates issues in his own workspace that reference Alice's project. Repeat 7 times: ```http POST /workspaces/W_B/issues Authorization: Bearer <Bob's token> Content-Type: application/json
{"title": "x", "project_id": "P_A", "status": "done"} ``` Each returns 201. Each issue is stored with `workspace_id = W_B` and `project_id = P_A`.
3. Alice reads her own project stats: `GET /workspaces/W_A/projects/P_A/stats` now returns `{"total": 8, "by_status": {"done": 7, "in_progress": 1}}`.
Bob is not a member of `W_A`, yet data he wrote appears in Alice's project dashboard.
## Impact
An unauthorized outsider can inflate or skew the issue counts shown in any workspace's project-statistics view, given only the target `project_id` (a UUID that can be harvested or guessed). The effect is limited to the statistics aggregation; it does not expose the victim's issue contents to the attacker and does not appear in the victim's workspace-scoped issue list. The same unvalidated write path also accepts cross-workspace `parent_issue_id` and `assignee_id` values, which have no aggregation read endpoint today but represent the same dangling cross-workspace reference class and should be fixed together.
## Suggested fix
On both issue create and update, validate that any body-supplied object reference resolves within the URL workspace before persisting, reusing the existing pattern:
```python if body.project_id is not None: project = await ProjectService(session).get(body.project_id, workspace_id=workspace_id) if project is None: raise HTTPException(404, "Project not found") ```
Apply the same check to `parent_issue_id` (via `require_issue_in_workspace`) and to `assignee_id`. As defense in depth, scope `get_stats` so it only counts issues whose `workspace_id` matches the project's workspace.
Are you affected?
Enter the version of the package you're using.
Affected packages
0 Fixed in: 0.1.8 pip install --upgrade 'praisonai-platform>=0.1.8'