VYPR
Critical severityGHSA Advisory· Published May 29, 2026

praisonai-platform: Any workspace member can promote themselves or others to owner via PATCH /workspaces/{id}/members/{user_id}

CVE-2026-47416

Description

Summary

Type: Vertical privilege escalation. The PATCH /workspaces/{workspace_id}/members/{user_id} endpoint is gated by require_workspace_member(workspace_id), which defaults to min_role="member" and is never overridden by the route. The handler then calls MemberService.update_role(workspace_id, user_id, body.role) which sets the target member's role to whatever the request body specifies, with no check that the caller has owner-or-admin privilege, no check that the new role is not higher than the caller's own, and no check that the caller is not silently promoting themselves. File: src/praisonai-platform/praisonai_platform/api/routes/workspaces.py, lines 115-127; services/member_service.py, lines 55-69; api/deps.py, lines 54-73. Root cause: require_workspace_member exists with a min_role parameter (deps.py:58) but FastAPI's Depends(require_workspace_member) cannot pass arguments, so every route uses the default "member". The route then passes the URL-supplied user_id and the body-supplied role directly to MemberService.update_role, which contains zero permission checks: it loads the member by composite key and assigns member.role = new_role. A user with the lowest possible privilege ("member") thus sets their own role to "owner" with one HTTP PATCH, completing a member-to-owner privilege escalation in a single request.

Affected

Code

File 1: src/praisonai-platform/praisonai_platform/api/routes/workspaces.py, lines 115-127.

@router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse)
async def update_member_role(
    workspace_id: str,
    user_id: str,
    body: MemberUpdate,
    user: AuthIdentity = Depends(require_workspace_member),         # <-- BUG: defaults to min_role="member"; no role gate
    session: AsyncSession = Depends(get_db),
):
    member_svc = MemberService(session)
    member = await member_svc.update_role(workspace_id, user_id, body.role)  # <-- writes any role to any member
    if member is None:
        raise HTTPException(status_code=404, detail="Member not found")
    return MemberResponse.model_validate(member)

File 2: src/praisonai-platform/praisonai_platform/services/member_service.py, lines 55-69.

async def update_role(
    self,
    workspace_id: str,
    user_id: str,
    new_role: str,
) -> Optional[Member]:
    """Update a member's role."""
    if new_role not in VALID_ROLES:                                  # only validates the *value*, not the *caller's right*
        raise ValueError(f"Invalid role: {new_role}. Must be one of {VALID_ROLES}")
    member = await self.get(workspace_id, user_id)
    if member is None:
        return None
    member.role = new_role                                           # <-- BUG: no caller-role check, no target-vs-caller hierarchy check
    await self._session.flush()
    return member

File 3: src/praisonai-platform/praisonai_platform/api/deps.py, lines 54-73.

async def require_workspace_member(
    workspace_id: str,
    user: AuthIdentity = Depends(get_current_user),
    session: AsyncSession = Depends(get_db),
    min_role: str = "member",                                        # <-- default that no route overrides
) -> AuthIdentity:
    member_svc = MemberService(session)
    has = await member_svc.has_role(workspace_id, user.id, min_role)
    if not has:
        raise HTTPException(status_code=403, detail="Not a member of this workspace or insufficient role")
    user.workspace_id = workspace_id
    return user

Why it's wrong: require_workspace_member was clearly designed to be tunable per-route — the min_role parameter is right there — but Depends(require_workspace_member) in FastAPI cannot pass arguments to a dependency, so every route resolves to the default "member". The author's intent is also evident in MemberService.has_role (member_service.py:80-96), which implements an owner > admin > member hierarchy that this endpoint should be enforcing. The endpoint uses none of it. The VALID_ROLES = {"owner", "admin", "member"} enum check (member_service.py:62) only validates the *new role string is recognised*, not that the *caller has the right to assign it*. As a result, a member can write {"role": "owner"} to their own membership row and become owner in one PATCH.

Exploit

Chain

  1. Attacker registers an account and joins (or is invited to) any workspace W as a "member" (the lowest privilege tier — typically anyone can be added by an owner during onboarding, or self-joins via an invite link). State: attacker has a JWT, is a Member(workspace_id=W, user_id=attacker, role="member").
  2. Attacker sends PATCH /workspaces/W/members/<attacker_user_id> with Authorization: Bearer <attacker_jwt> and body {"role": "owner"}. State: control flow enters update_member_role.
  3. require_workspace_member(W, attacker) runs. Its default min_role="member" is satisfied because the attacker is a member. The dependency returns the attacker's identity. State: route handler proceeds with no further role gate.
  4. MemberService.update_role(W, attacker, "owner") runs. VALID_ROLES accepts "owner". self.get(W, attacker) returns the attacker's existing member row. The next line, member.role = "owner", mutates the attacker's role in place. await self._session.flush() commits. State: attacker is now Member(workspace_id=W, user_id=attacker, role="owner").
  5. Attacker re-issues GET /auth/me (or any owner-gated endpoint) and is now treated as workspace owner. State: full administrative control of the workspace, including the ability to add/remove members, change settings, delete the workspace, and exfiltrate everything via the agent/issue/project/comment IDORs that were filed as separate advisories.
  6. Final state: starting from the lowest workspace privilege, the attacker holds owner of the workspace within one HTTP request. The same primitive also lets the attacker DEMOTE the legitimate owner by sending PATCH /workspaces/W/members/<owner_user_id> with {"role": "member"} — owner lockout in two requests total.

Security

Impact

Severity: sec-critical. CVSS 9.1: network attack, low complexity, low privileges (the lowest tier on the platform), no user interaction, scope changed (the privilege boundary the attacker crosses is the workspace owner, a different security principal), high confidentiality and integrity (full workspace control), no availability claim (the attacker can also DELETE the workspace via the companion delete_workspace advisory, but that is a separate finding). Attacker capability: with one workspace-member token plus one PATCH request, the attacker becomes workspace owner. From there: add/remove any user as owner, change every workspace setting (including the settings JSON blob), demote the legitimate owner to "member", or chain into the companion delete_workspace advisory to wipe the workspace entirely. In multi-tenant SaaS deployments where any signup yields a member-level account in some default workspace, this is effectively pre-auth. Preconditions: praisonai-platform is deployed multi-tenant (more than one workspace exists OR the deployment grants member access on signup); the attacker has any membership token in the target workspace. Differential: source-inspection-verified end-to-end. The asymmetry between require_workspace_member's min_role parameter (which exists, defaults to "member", and is never overridden) and MemberService.has_role's clearly tiered owner > admin > member hierarchy (which exists but is never invoked with anything but the default) is the smoking gun. With the suggested fix below, the route resolves with min_role="owner", the attacker's member-level token fails the gate at the dependency, and the privilege escalation never reaches the service layer.

Suggested

Fix

The fix has two parts. First, the route must resolve require_workspace_member with min_role="owner" (or at least "admin"). Second, MemberService.update_role should refuse to set a target's role higher than the caller's own role, so that an admin cannot accidentally produce another owner.

--- a/src/praisonai-platform/praisonai_platform/api/routes/workspaces.py
+++ b/src/praisonai-platform/praisonai_platform/api/routes/workspaces.py
@@ -115,11 +115,16 @@
+def _require_owner(workspace_id: str, user, session):
+    return require_workspace_member(workspace_id, user, session, min_role="owner")
+
 @router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse)
 async def update_member_role(
     workspace_id: str,
     user_id: str,
     body: MemberUpdate,
-    user: AuthIdentity = Depends(require_workspace_member),
+    user: AuthIdentity = Depends(_require_owner),
     session: AsyncSession = Depends(get_db),
 ):
     member_svc = MemberService(session)
+    if not await member_svc.has_role(workspace_id, user.id, "owner"):
+        raise HTTPException(status_code=403, detail="Only owners can change member roles")
     member = await member_svc.update_role(workspace_id, user_id, body.role)

Defence-in-depth in the service layer:

--- a/src/praisonai-platform/praisonai_platform/services/member_service.py
+++ b/src/praisonai-platform/praisonai_platform/services/member_service.py
@@ -55,7 +55,7 @@
-    async def update_role(self, workspace_id: str, user_id: str, new_role: str) -> Optional[Member]:
+    async def update_role(self, workspace_id: str, caller_id: str, user_id: str, new_role: str) -> Optional[Member]:
         """Update a member's role."""
+        if not await self.has_role(workspace_id, caller_id, "owner"):
+            raise PermissionError("Only owners can update member roles")
         if new_role not in VALID_ROLES:
             raise ValueError(...)

The companion endpoints add_member, remove_member, delete_workspace, and update_workspace exhibit the same Depends(require_workspace_member) default-min-role pattern and are filed as their own advisories so each gets a separate CVE.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Any workspace member can escalate to owner via PATCH endpoint due to missing privilege check in FastAPI dependency; fix in v0.0.47+.

Vulnerability

The PATCH /workspaces/{workspace_id}/members/{user_id} endpoint in PraisonAI Platform (versions before v0.0.47) lacks any authorization check on the caller's role. The route handler uses Depends(require_workspace_member) which defaults to min_role="member" and cannot be overridden via FastAPI's dependency injection. Consequently, any authenticated workspace member can send a PATCH request with an arbitrary role value (e.g., "owner") in the request body. The MemberService.update_role() function then directly writes the supplied role into the database for the specified (or own) user_id without verifying that the caller has sufficient privileges. The vulnerable code resides in src/praisonai-platform/praisonai_platform/api/routes/workspaces.py (lines 115-127) and services/member_service.py (lines 55-69) [1][2].

Exploitation

An attacker needs only valid authentication as a workspace member (the lowest privilege level) and network access to the API. The exploit requires a single HTTP PATCH request to the /workspaces/{workspace_id}/members/{user_id} endpoint, where the user_id can be set to the attacker's own user ID and the request body contains {"role": "owner"}. Because the route never verifies the caller's existing role or restricts the target role, the server immediately updates the role in the database. No additional user interaction, race window, or elevated privileges are needed [1][2].

Impact

Successful exploitation results in vertical privilege escalation from workspace member to owner. An attacker who gains owner privileges can perform all administrative actions within that workspace: manage other members, change workspace settings, access all data, and potentially delete or export sensitive information. This compromises the confidentiality, integrity, and availability of the workspace and its contents [1][2].

Mitigation

The vulnerability is fixed in PraisonAI Platform v0.0.47 and later, released on May 29, 2026. The fix adds explicit role validation in the route handler or dependency, ensuring that only users with at least admin or owner role can modify member roles and that the target role does not exceed the caller's own privilege. Users are strongly advised to upgrade immediately. No known workarounds exist for unpatched versions, and the vulnerability is not currently listed on CISA's Known Exploited Vulnerabilities (KEV) catalog [1][2].

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
praisonai-platformPyPI
< 0.1.40.1.4

Affected products

2

Patches

1
8b72cec167ec

Merge pull request #168 from MervinPraison/develop

https://github.com/MervinPraison/PraisonAIMervin PraisonOct 8, 2024Fixed in 0.1.3via release-tag
6 files changed · +25 18
  • Dockerfile+1 1 modified
    @@ -1,6 +1,6 @@
     FROM python:3.11-slim
     WORKDIR /app
     COPY . .
    -RUN pip install flask praisonai==0.1.2 gunicorn markdown
    +RUN pip install flask praisonai==0.1.3 gunicorn markdown
     EXPOSE 8080
     CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]
    
  • docs/api/praisonai/deploy.html+1 1 modified
    @@ -110,7 +110,7 @@ <h2 id="raises">Raises</h2>
                 file.write(&#34;FROM python:3.11-slim\n&#34;)
                 file.write(&#34;WORKDIR /app\n&#34;)
                 file.write(&#34;COPY . .\n&#34;)
    -            file.write(&#34;RUN pip install flask praisonai==0.1.2 gunicorn markdown\n&#34;)
    +            file.write(&#34;RUN pip install flask praisonai==0.1.3 gunicorn markdown\n&#34;)
                 file.write(&#34;EXPOSE 8080\n&#34;)
                 file.write(&#39;CMD [&#34;gunicorn&#34;, &#34;-b&#34;, &#34;0.0.0.0:8080&#34;, &#34;api:app&#34;]\n&#39;)
                 
    
  • praisonai/deploy.py+1 1 modified
    @@ -56,7 +56,7 @@ def create_dockerfile(self):
                 file.write("FROM python:3.11-slim\n")
                 file.write("WORKDIR /app\n")
                 file.write("COPY . .\n")
    -            file.write("RUN pip install flask praisonai==0.1.2 gunicorn markdown\n")
    +            file.write("RUN pip install flask praisonai==0.1.3 gunicorn markdown\n")
                 file.write("EXPOSE 8080\n")
                 file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
                 
    
  • praisonai.rb+1 1 modified
    @@ -3,7 +3,7 @@ class Praisonai < Formula
       
         desc "AI tools for various AI applications"
         homepage "https://github.com/MervinPraison/PraisonAI"
    -    url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/0.1.2.tar.gz"
    +    url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/0.1.3.tar.gz"
         sha256 "1828fb9227d10f991522c3f24f061943a254b667196b40b1a3e4a54a8d30ce32"  # Replace with actual SHA256 checksum
         license "MIT"
       
    
  • praisonai/ui/realtime.py+20 13 modified
    @@ -6,7 +6,6 @@
     
     from openai import AsyncOpenAI
     import chainlit as cl
    -from chainlit.logger import logger
     from chainlit.input_widget import TextInput
     from chainlit.types import ThreadDict
     
    @@ -16,6 +15,25 @@
     import chainlit.data as cl_data
     from literalai.helper import utc_now
     import json
    +import logging
    +import importlib.util
    +from importlib import import_module
    +from pathlib import Path
    +
    +# Set up logging
    +logger = logging.getLogger(__name__)
    +log_level = os.getenv("LOGLEVEL", "INFO").upper()
    +logger.handlers = []
    +
    +# Set up logging to console
    +console_handler = logging.StreamHandler()
    +console_handler.setLevel(log_level)
    +console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    +console_handler.setFormatter(console_formatter)
    +logger.addHandler(console_handler)
    +
    +# Set the logging level for the logger
    +logger.setLevel(log_level)
     
     # Set up CHAINLIT_AUTH_SECRET
     CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
    @@ -144,19 +162,8 @@ def load_setting(key: str) -> str:
     
     client = AsyncOpenAI()
     
    -# Add these new imports and code
    -import importlib.util
    -import logging
    -from importlib import import_module
    -from pathlib import Path
    -
    -# Set up logging
    -logging.basicConfig(level=logging.INFO)
    -logger = logging.getLogger(__name__)
    -
     # Try to import tools from the root directory
    -root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    -tools_path = os.path.join(root_dir, 'tools.py')
    +tools_path = os.path.join(os.getcwd(), 'tools.py')
     logger.info(f"Tools path: {tools_path}")
     
     def import_tools_from_file(file_path):
    
  • pyproject.toml+1 1 modified
    @@ -1,6 +1,6 @@
     [tool.poetry]
     name = "PraisonAI"
    -version = "0.1.2"
    +version = "0.1.3"
     description = "PraisonAI application combines AutoGen and CrewAI or similar frameworks into a low-code solution for building and managing multi-agent LLM systems, focusing on simplicity, customization, and efficient human-agent collaboration."
     authors = ["Mervin Praison"]
     license = ""
    

Vulnerability mechanics

Root cause

"The `require_workspace_member` dependency defaults to `min_role="member"` and is never overridden by the route, while `MemberService.update_role` performs no caller-authorization check before writing the requested role to the database."

Attack vector

An attacker who is already a workspace member (the lowest privilege tier) sends a single `PATCH /workspaces/{workspace_id}/members/{user_id}` request with a JSON body of `{"role": "owner"}` and their own `user_id` in the URL. The `require_workspace_member` dependency passes because the default `min_role="member"` is satisfied [ref_id=1][ref_id=2]. The route then calls `MemberService.update_role`, which validates only that the new role string is one of `{"owner", "admin", "member"}` but never checks whether the caller has the right to assign that role [ref_id=1][ref_id=2]. The attacker's role is immediately promoted to owner, granting full administrative control of the workspace.

Affected code

The vulnerability resides in three files: `src/praisonai-platform/praisonai_platform/api/routes/workspaces.py` (lines 115–127), `src/praisonai-platform/praisonai_platform/services/member_service.py` (lines 55–69), and `src/praisonai-platform/praisonai_platform/api/deps.py` (lines 54–73). The `require_workspace_member` dependency defaults to `min_role="member"` and is never overridden by the route, while `MemberService.update_role` performs no caller-authorization check before writing the requested role to the database.

What the fix does

The suggested fix adds a wrapper dependency `_require_owner` that calls `require_workspace_member` with `min_role="owner"`, so only existing owners can reach the handler [ref_id=1][ref_id=2]. Additionally, `MemberService.update_role` is modified to accept a `caller_id` parameter and verify that the caller holds the `"owner"` role before allowing any role change. This ensures that a member-level token is rejected at the dependency layer and never reaches the service logic that writes the new role.

Preconditions

  • authThe attacker must be a member of the target workspace (any role, including the lowest 'member' tier).
  • authThe attacker must have a valid JWT for the platform.
  • configThe platform must be deployed in a multi-tenant configuration where member-level access is obtainable (e.g., via signup or invite link).

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.