CVE-2026-47416

criticalpriority · track · 0OT risk · elevatedEPSS 0.003 · 18 %ileEUUS
grimoire priorityP2 · Scheduled
Exploitable?
—
no known exploitation
Likely / severe?
0.26% (18th) EPSS
CVSS 9.6 · critical
Is it mine?
2 products
Check your exposure
Who says so?
3 sources
cnnvd · enisa_euvd · osv

EPSS · scored 2026-10-27

PraisonAI Platform is the platform layer for the PraisonAI multi-agent teams system. Versions prior to 0.1.4 are vulnerable to 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. PraisonAI Platform version 0.1.4 patches the issue.

01

cvss

Technical severity, 0–10. One row per scoring source.

9.6
010

base score 0–10 · higher = more severe · hover a vector to decode⚠ sources disagree · high–critical · 8.8–9.6

v3.19.6criticalCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:Nsecondary · ENISA EU
v3.19.6criticalCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:Nsecondary · security-advisories@github.comexploitability 3.1 · impact 5.8
v3.19.6criticalCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:Nsecondary · OSV
v3.18.8highCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N/E:Usecondary · CVSS-BT (t0sche)
02

ssvc

A stakeholder decision — act, attend, or track — from exploitation and impact.

Exploitation: poc · Automatable: no · Technical Impact: totalcoordinatorcisa_ssvcUS
03

weaknesses

The CWE weakness classes this flaw belongs to.

04

affected products (2)

Products and version ranges known to be affected.

05

advisories (2)

Vendor and ecosystem advisories describing this.

  • GHSA-c2m8-4gcg-v22gpraisonai-platform: Any workspace member can promote themselves or others to owner via PATCH /workspaces/{id}/members/{user_id}2026-05-29ghsa · osvUS

    ## 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. ```python @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. ```python 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. ```python 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. ```diff --- 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: ```diff --- 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.

  • PYSEC-2026-481praisonai-platform: Any workspace member can promote themselves or others to owner via PATCH /workspaces/{id}/members/{user_id}2026-06-29vendor · osvUS

    ## 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. ```python @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. ```python 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. ```python 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. ```diff --- 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: ```diff --- 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.

06

also known as (6)

Other names and identifiers for this entity.

07

sources

Which sources assert this record, and when.

published 2026-07-21modified 2026-10-24
08

references (7)

External citations — patches, advisories, analysis.

scry

Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.

Free scan