VAITP Dataset

← Back to the dataset

CVE-2026-47407

Broken access control enables cross-tenant access and workspace takeover.

  • CVSS 9.4
  • CWE-269
  • Authentication, Authorization, and Session Management
  • Remote

PraisonAI Platform is the platform layer for the PraisonAI multi-agent teams system. Prior to version 0.1.4, the Platform server exposes resources under `/api/v1/workspaces/{workspace_id}/…` and protects them with a `require_workspace_member(workspace_id)` FastAPI dependency. The dependency only checks that the caller is a member of the workspace_id in the URL prefix. The route handlers then look up the inner resource (`agent_id`, `issue_id`, `project_id`, `label_id`, `comment_id`, `dependency_id`) by primary key alone. The resource's own `workspace_id` is never compared to the URL's `workspace_id`. A user can therefore put their own workspace in the URL prefix and any other workspace's resource ID in the path. The auth check passes, since they really are a member of the prefix workspace. The service then returns the cross-tenant resource for read, update, or delete. There is a second bug in the member-management routes (`add_member`, `update_member_role`, `remove_member`, `update_workspace`, `delete_workspace`). Each one inherits the default `min_role="member"` from `require_workspace_member`. Any basic member can therefore promote themselves to admin or owner, demote or remove other members, and delete the workspace. The role hierarchy exists in the schema but is not enforced. Registration is open at `/api/v1/auth/register` with no email verification. The default server bind is `0.0.0.0:8000` (`python -m praisonai_platform`). One curl from any unauthenticated network position is enough to bootstrap into the system. PraisonAI Platform version 0.1.4 patches the issue.

CVSS base score
9.4
Published
2026-07-21
OWASP
A01 Broken Access Control
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Authentication, Authorization, and Session Management
Subcategory
Insecure Direct Object References (IDOR)
Accessibility scope
Remote
Impact
Privilege Escalation
Affected component
PraisonAI Pl
Fixed by upgrading
Yes

Solution

Upgrade to PraisonAI Platform version 0.1.4.

Vulnerable code sample

import uvicorn
from fastapi import FastAPI, Depends, HTTPException, Header, status
from pydantic import BaseModel
from typing import Dict, Any, Literal

# --- Mock Database and Models for Demonstration ---
DB = {
    "users": {
        1: {"username": "attacker"},
        2: {"username": "victim_admin"},
    },
    "workspaces": {
        100: {"name": "Attacker's Workspace"},
        200: {"name": "Victim's Workspace"},
    },
    "workspace_members": [
        {"user_id": 1, "workspace_id": 100, "role": "member"}, # Attacker
        {"user_id": 2, "workspace_id": 200, "role": "owner"},  # Victim
    ],
    "projects": {
        # A sensitive project in the victim's workspace
        5001: {"name": "Project Q", "details": "secret_info_for_project_q", "workspace_id": 200},
        # A project in the attacker's workspace
        9001: {"name": "My Project", "details": "public_data", "workspace_id": 100},
    }
}

class Project(BaseModel):
    name: str
    details: str
    workspace_id: int

class UpdateRolePayload(BaseModel):
    role: Literal["member", "admin", "owner"]

# --- FastAPI Application ---
app = FastAPI()

# --- Mock User and Auth Dependency ---
def get_current_user_id(x_user_id: int = Header(...)) -> int:
    if x_user_id not in DB["users"]:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user")
    return x_user_id

def require_workspace_member(workspace_id: int, min_role: str = "member"):
    """
    Dependency factory. This is the core of the flawed authorization check.
    It only checks if the user is part of the workspace_id from the URL prefix.
    """
    def dependency(current_user_id: int = Depends(get_current_user_id)) -> bool:
        for member in DB["workspace_members"]:
            if member["user_id"] == current_user_id and member["workspace_id"] == workspace_id:
                # The check is successful if the user is found in the workspace.
                # The role hierarchy is not properly enforced here or in the route.
                return True
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=f"User is not a member of workspace {workspace_id}",
        )
    return dependency

# --- VULNERABLE ENDPOINTS ---

@app.get("/api/v1/workspaces/{workspace_id}/projects/{project_id}", response_model=Project)
def get_project(
    workspace_id: int,
    project_id: int,
    is_member: bool = Depends(require_workspace_member(workspace_id))
):
    """
    VULNERABILITY 1: Insecure Direct Object Reference (IDOR).
    An authenticated user can access a resource in any workspace by providing
    their own workspace_id in the URL prefix and the target resource's ID.

    The authorization check passes because the user is a member of the `workspace_id`
    they provided. The handler then looks up the `project_id` by its primary key
    alone, failing to validate that the found project belongs to the `workspace_id`
    from the URL.
    """
    if project_id not in DB["projects"]:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")

    # VULNERABLE LOGIC: The project is fetched by its ID without verifying
    # that its `workspace_id` matches the one from the URL.
    project = DB["projects"][project_id]

    return project

@app.put("/api/v1/workspaces/{workspace_id}/members/{member_user_id}/role")
def update_member_role(
    workspace_id: int,
    member_user_id: int,
    payload: UpdateRolePayload,
    is_member: bool = Depends(require_workspace_member(workspace_id, min_role="member"))
):
    """
    VULNERABILITY 2: Privilege Escalation.
    Any member of a workspace can change the role of any other member, including
    promoting themselves to "admin" or "owner".

    The route is protected by `require_workspace_member` which defaults to requiring
    only a "member" role. The handler itself does not implement any further
    role-based access control to ensure the acting user is an admin or owner.
    """
    member_to_update = None
    for member in DB["workspace_members"]:
        if member["user_id"] == member_user_id and member["workspace_id"] == workspace_id:
            member_to_update = member
            break

    if not member_to_update:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found in workspace")

    # VULNERABLE LOGIC: The role is updated without checking if the current user
    # (from the header) has the permission (e.g., is an owner) to do so.
    member_to_update["role"] = payload.role

    return {"message": f"User {member_user_id}'s role in workspace {workspace_id} updated to {payload.role}"}

Patched code sample

import uvicorn
from fastapi import FastAPI, Depends, HTTPException, Path
from pydantic import BaseModel
from typing import Dict, Literal, Optional

# --- Mock Data Models ---
# Role hierarchy: Owner > Admin > Member
ROLE_HIERARCHY = {"owner": 3, "admin": 2, "member": 1}
UserRole = Literal["owner", "admin", "member"]

class User(BaseModel):
    id: int
    username: str

class WorkspaceMember(BaseModel):
    user_id: int
    workspace_id: str
    role: UserRole

class Agent(BaseModel):
    id: str
    name: str
    workspace_id: str  # The crucial field for the fix

# --- Mock Database ---
# A simple in-memory "database" to simulate data storage
DB_USERS = {1: User(id=1, username="user_one")}
DB_AGENTS = {
    "agent_abc": Agent(id="agent_abc", name="Agent from Workspace A", workspace_id="workspace_A"),
    "agent_xyz": Agent(id="agent_xyz", name="Agent from Workspace B", workspace_id="workspace_B"),
}
DB_MEMBERSHIPS = {
    # User 1 is a 'member' in Workspace A and an 'owner' in Workspace C
    (1, "workspace_A"): WorkspaceMember(user_id=1, workspace_id="workspace_A", role="member"),
    (1, "workspace_C"): WorkspaceMember(user_id=1, workspace_id="workspace_C", role="owner"),
}

# --- Mock Authentication and DB Access ---
def get_current_user() -> User:
    """Simulates getting the currently authenticated user."""
    return DB_USERS[1]

def get_agent_from_db(agent_id: str) -> Optional[Agent]:
    """Simulates fetching an agent by its primary key."""
    return DB_AGENTS.get(agent_id)

# --- Patched Authorization Dependency ---
def require_workspace_member(min_role: UserRole = "member"):
    """
    This is the patched dependency factory.
    FIX 1: It now accepts a `min_role` to enforce role-based access control.
    """
    def dependency(
        workspace_id: str = Path(...),
        current_user: User = Depends(get_current_user)
    ) -> WorkspaceMember:
        membership = DB_MEMBERSHIPS.get((current_user.id, workspace_id))

        if not membership:
            raise HTTPException(status_code=403, detail="Not a member of this workspace")

        # This check enforces the role hierarchy, fixing the privilege escalation flaw.
        # A basic 'member' can no longer call routes requiring 'admin' or 'owner'.
        if ROLE_HIERARCHY[membership.role] < ROLE_HIERARCHY[min_role]:
            raise HTTPException(status_code=403, detail=f"Insufficient permissions. Requires role: {min_role}")

        return membership
    return dependency

# --- FastAPI Application with Patched Routes ---
app = FastAPI()

@app.get("/api/v1/workspaces/{workspace_id}/agents/{agent_id}", response_model=Agent)
def get_agent(
    agent_id: str = Path(...),
    # The dependency still checks for basic membership for read operations.
    membership: WorkspaceMember = Depends(require_workspace_member(min_role="member")),
):
    """
    This route demonstrates the fix for the Insecure Direct Object Reference (IDOR).
    A user in 'workspace_A' cannot access an agent from 'workspace_B'.
    """
    agent = get_agent_from_db(agent_id)

    # FIX 2: This check ensures the retrieved resource belongs to the workspace specified
    # in the URL, preventing cross-tenant data leakage.
    if not agent or agent.workspace_id != membership.workspace_id:
        # We return 404 instead of 403 to avoid leaking information about
        # whether the agent_id exists in another tenant's workspace.
        raise HTTPException(status_code=404, detail="Agent not found")

    return agent

@app.delete("/api/v1/workspaces/{workspace_id}")
def delete_workspace(
    # This route now correctly requires the 'owner' role by passing it to the
    # patched dependency, fixing the privilege escalation vulnerability.
    membership: WorkspaceMember = Depends(require_workspace_member(min_role="owner")),
):
    """
    This route demonstrates the fix for the privilege escalation vulnerability.
    Only an 'owner' can delete the workspace.
    """
    # In a real app, logic to delete the workspace would go here.
    # e.g., del DB_WORKSPACES[membership.workspace_id]
    return {"message": f"Workspace '{membership.workspace_id}' deleted successfully."}

# To run this example:
# 1. Install dependencies: pip install fastapi "uvicorn[standard]"
# 2. Run the server: uvicorn name_of_this_file:app --reload
#
# Example failing curl (IDOR):
# User from 'workspace_A' tries to access agent from 'workspace_B'. This will now fail with 404.
# curl -X GET http://127.0.0.1:8000/api/v1/workspaces/workspace_A/agents/agent_xyz
#
# Example failing curl (Privilege Escalation):
# 'member' of 'workspace_A' tries to delete it. This will now fail with 403.
# curl -X DELETE http://127.0.0.1:8000/api/v1/workspaces/workspace_A
#
# Example successful curl:
# 'owner' of 'workspace_C' deletes it. This will succeed.
# curl -X DELETE http://127.0.0.1:8000/api/v1/workspaces/workspace_C

Payload

curl -X DELETE -H "Authorization: Bearer <attacker_token>" http://<target_ip>:8000/api/v1/workspaces/<attacker_workspace_id>/projects/<victim_project_id>

Cite this entry

@misc{vaitp:cve202647407,
  title        = {{Broken access control enables cross-tenant access and workspace takeover.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-47407},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-47407/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::