VYPR
High severityGHSA Advisory· Published May 29, 2026

praisonai-platform: Label endpoints' unchecked label_id/issue_id enable cross-workspace label IDOR (edit, delete, link)

CVE-2026-47414

Description

Summary

Type: Insecure Direct Object Reference. Five label endpoints — PATCH /workspaces/{workspace_id}/labels/{label_id}, DELETE .../labels/{label_id}, POST .../issues/{issue_id}/labels/{label_id}, DELETE .../issues/{issue_id}/labels/{label_id}, GET .../issues/{issue_id}/labels — gate access on require_workspace_member(workspace_id) only and pass URL-supplied label_id and issue_id straight through to LabelService without verifying either belongs to the workspace. File: src/praisonai-platform/praisonai_platform/services/label_service.py, lines 35-100; route handlers at src/praisonai-platform/praisonai_platform/api/routes/labels.py, lines 42-106. Root cause: identical pattern to the agent / issue / project / comment IDORs in this codebase: the route's workspace_id is used as a membership predicate but never threaded through to the service layer. LabelService.get(label_id) runs session.get(IssueLabel, label_id) with no workspace filter; update/delete inherit the gap; add_to_issue(issue_id, label_id) and remove_from_issue(issue_id, label_id) write/delete association rows without verifying either ID belongs to the membership-checked workspace; list_for_issue(issue_id) reads them.

Affected

Code

File 1: src/praisonai-platform/praisonai_platform/services/label_service.py, lines 35-100.

class LabelService:
    ...

    async def get(self, label_id: str) -> Optional[IssueLabel]:
        return await self._session.get(IssueLabel, label_id)         # <-- BUG: no workspace_id predicate

    async def update(
        self,
        label_id: str,
        ...
    ) -> Optional[IssueLabel]:
        label = await self.get(label_id)                             # <-- inherits the gap
        ...

    async def delete(self, label_id: str) -> bool:
        label = await self.get(label_id)                             # <-- inherits the gap
        ...

    async def add_to_issue(self, issue_id: str, label_id: str) -> None:
        # writes a row in issue_label association table; no workspace check on either id

    async def remove_from_issue(self, issue_id: str, label_id: str) -> None:
        # deletes from association table; no workspace check on either id

    async def list_for_issue(self, issue_id: str) -> list[IssueLabel]:
        # reads from association table; no workspace check on issue_id

File 2: src/praisonai-platform/praisonai_platform/api/routes/labels.py, lines 42-106.

@router.patch("/labels/{label_id}", response_model=LabelResponse)
async def update_label(workspace_id: str, label_id: str, body: LabelUpdate, ...):
    svc = LabelService(session)
    label = await svc.update(label_id, body.name, body.color)        # <-- writes any label in the DB
    ...

@router.delete("/labels/{label_id}", ...)
async def delete_label(workspace_id: str, label_id: str, ...):
    deleted = await svc.delete(label_id)                             # <-- deletes any label in the DB
    ...

@router.post("/issues/{issue_id}/labels/{label_id}", ...)
async def add_label_to_issue(workspace_id: str, issue_id: str, label_id: str, ...):
    await svc.add_to_issue(issue_id, label_id)                       # <-- attaches any label to any issue cross-workspace

@router.delete("/issues/{issue_id}/labels/{label_id}", ...)
async def remove_label_from_issue(workspace_id: str, issue_id: str, label_id: str, ...):
    await svc.remove_from_issue(issue_id, label_id)                  # <-- detaches any label from any issue cross-workspace

@router.get("/issues/{issue_id}/labels", ...)
async def list_issue_labels(workspace_id: str, issue_id: str, ...):
    labels = await svc.list_for_issue(issue_id)                      # <-- reads label assignments for any issue

Why it's wrong: the workspace_id URL segment is treated as a UI hint; the actual label_id and issue_id lookups query the database without a workspace constraint. The MemberService in this same codebase uses a composite key correctly; the label service does not. The add_to_issue and remove_from_issue paths are particularly nasty because they touch *two* unverified IDs at once: an attacker can attach a foreign workspace's label to a foreign workspace's issue (or detach the legitimate labels), corrupting both sides of an association the attacker has no business touching.

Exploit

Chain

  1. Attacker registers a workspace W_attacker (member) and harvests a foreign-workspace label_id L_T and a foreign-workspace issue_id I_T. Both leak via list_labels responses (which include label IDs — but only for W_attacker; for the target the IDs come from issue records that include label associations, activity feeds, exported dumps, error messages). State: attacker holds L_T and I_T.
  2. Attacker authenticates and sends PATCH /workspaces/W_attacker/labels/L_T with {"name": "", "color": "#000000"}. require_workspace_member(W_attacker, attacker) passes. LabelService.update(L_T, ...) loads the foreign label and renames it. State: every issue across the foreign workspace that bears this label now displays the attacker-chosen name and colour.
  3. Attacker sends DELETE /workspaces/W_attacker/labels/L_T. LabelService.delete(L_T) deletes the foreign label, dropping every issue-label association row that referenced it (cascade or orphan, depending on schema). State: foreign workspace's labels are gone or corrupted.
  4. Attacker sends POST /workspaces/W_attacker/issues/I_T/labels/L_T2 to attach foreign label L_T2 to foreign issue I_T. LabelService.add_to_issue(I_T, L_T2) writes the association row regardless of either ID's workspace. State: the foreign issue now carries an arbitrary attacker-chosen label, which surfaces in every filter/search/board view in the foreign workspace's UI.
  5. Attacker sends DELETE /workspaces/W_attacker/issues/I_T/labels/L_legit to strip the legitimate label off the foreign issue. State: triagers can no longer find the issue via label filters.
  6. Attacker sends GET /workspaces/W_attacker/issues/I_T/labels to read the current label set on any foreign issue. State: the attacker fingerprints the foreign workspace's triage taxonomy.
  7. Final state: with one workspace-member token plus harvested foreign IDs, the attacker rewrites and deletes other workspaces' labels, attaches/detaches arbitrary labels on other workspaces' issues, and reads triage state across the deployment.

Security

Impact

Severity: sec-moderate. CVSS 6.3: network attack, low complexity, low privileges, no user interaction, scope unchanged. The integrity damage is high (rename/delete of foreign labels is permanent and silent; cross-workspace label-attachment corrupts UI filters), confidentiality is low (label names are not the most sensitive field but do leak triage taxonomy), availability low (foreign workspaces may lose triage visibility into their own issues until the labels are restored). Attacker capability: rename and delete any label in the multi-tenant deployment; attach any label to any issue; detach any label from any issue; list label assignments for any issue. Combined with the companion IssueService IDOR (separate advisory), the attacker can also modify the underlying issue, making the cross-workspace tampering very difficult to detect. Preconditions: praisonai-platform is deployed multi-tenant; the attacker has any membership token; target IDs are known or guessable. Differential: source-inspection-verified end-to-end. The asymmetry between LabelService.list_for_workspace(workspace_id) (correctly workspace-scoped) and LabelService.get(label_id) / add_to_issue(issue_id, label_id) (no workspace check) confirms the gap. With the suggested fix below, label and issue IDs that do not belong to the membership-checked workspace return 404, and the attacker cannot touch them.

Suggested

Fix

Make every single-row label lookup take the workspace predicate; verify both issue_id and label_id belong to workspace_id for the association routes.

--- a/src/praisonai-platform/praisonai_platform/services/label_service.py
+++ b/src/praisonai-platform/praisonai_platform/services/label_service.py
@@ -33,7 +33,12 @@ class LabelService:
         return label

-    async def get(self, label_id: str) -> Optional[IssueLabel]:
-        return await self._session.get(IssueLabel, label_id)
+    async def get(self, workspace_id: str, label_id: str) -> Optional[IssueLabel]:
+        stmt = select(IssueLabel).where(
+            IssueLabel.id == label_id,
+            IssueLabel.workspace_id == workspace_id,
+        )
+        return (await self._session.execute(stmt)).scalar_one_or_none()

-    async def add_to_issue(self, issue_id: str, label_id: str) -> None:
+    async def add_to_issue(self, workspace_id: str, issue_id: str, label_id: str) -> None:
+        # Verify both ids belong to workspace_id before writing the association row.

Then update the route handlers in routes/labels.py to thread workspace_id through every call. The same single-key-lookup pattern is filed separately for AgentService, IssueService, ProjectService, and CommentService — each is its own exploitable IDOR.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Insecure Direct Object Reference in PraisonAI label endpoints permits cross-workspace label manipulation.

Vulnerability

Five label endpoints in the PraisonAI platform (PATCH /workspaces/{workspace_id}/labels/{label_id}, DELETE .../labels/{label_id}, POST .../issues/{issue_id}/labels/{label_id}, DELETE .../issues/{issue_id}/labels/{label_id}, GET .../issues/{issue_id}/labels) enforce workspace membership via require_workspace_member(workspace_id) but pass the URL-supplied label_id and issue_id directly to LabelService without verifying that either belongs to the workspace. The LabelService.get() method (line 35 of label_service.py) uses session.get(IssueLabel, label_id) with no workspace filter; update, delete, add_to_issue, remove_from_issue, and list_for_issue inherit this gap [1][2]. This affects all versions prior to the fix.

Exploitation

An attacker who is a member of any workspace can send crafted requests to the vulnerable endpoints, substituting arbitrary label_id or issue_id values from other workspaces. No additional authentication is required beyond a valid session as a workspace member. The attacker does not need to be a member of the target workspace [1][2].

Impact

Successful exploitation allows an attacker to read, update, or delete labels belonging to any workspace, as well as add or remove labels from issues in any workspace. This results in unauthorized information disclosure (e.g., label metadata) and data tampering, potentially disrupting issue tracking across workspaces [1][2].

Mitigation

The vendor has not yet released a patched version. As a workaround, implement workspace ownership checks in LabelService methods by verifying that the label_id and issue_id belong to the workspace_id provided in the URL. Monitor the advisory page [1][2] for updates.

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
praisonai-platformPyPI
< 0.1.40.1.4

Affected products

1

Patches

1
8b72cec167ec

Merge pull request #168 from MervinPraison/develop

https://github.com/MervinPraison/PraisonAIMervin PraisonOct 8, 2024Fixed in 0.1.3via release-tag
6 files changed · +25 18
  • Dockerfile+1 1 modified
    @@ -1,6 +1,6 @@
     FROM python:3.11-slim
     WORKDIR /app
     COPY . .
    -RUN pip install flask praisonai==0.1.2 gunicorn markdown
    +RUN pip install flask praisonai==0.1.3 gunicorn markdown
     EXPOSE 8080
     CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]
    
  • docs/api/praisonai/deploy.html+1 1 modified
    @@ -110,7 +110,7 @@ <h2 id="raises">Raises</h2>
                 file.write(&#34;FROM python:3.11-slim\n&#34;)
                 file.write(&#34;WORKDIR /app\n&#34;)
                 file.write(&#34;COPY . .\n&#34;)
    -            file.write(&#34;RUN pip install flask praisonai==0.1.2 gunicorn markdown\n&#34;)
    +            file.write(&#34;RUN pip install flask praisonai==0.1.3 gunicorn markdown\n&#34;)
                 file.write(&#34;EXPOSE 8080\n&#34;)
                 file.write(&#39;CMD [&#34;gunicorn&#34;, &#34;-b&#34;, &#34;0.0.0.0:8080&#34;, &#34;api:app&#34;]\n&#39;)
                 
    
  • praisonai/deploy.py+1 1 modified
    @@ -56,7 +56,7 @@ def create_dockerfile(self):
                 file.write("FROM python:3.11-slim\n")
                 file.write("WORKDIR /app\n")
                 file.write("COPY . .\n")
    -            file.write("RUN pip install flask praisonai==0.1.2 gunicorn markdown\n")
    +            file.write("RUN pip install flask praisonai==0.1.3 gunicorn markdown\n")
                 file.write("EXPOSE 8080\n")
                 file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
                 
    
  • praisonai.rb+1 1 modified
    @@ -3,7 +3,7 @@ class Praisonai < Formula
       
         desc "AI tools for various AI applications"
         homepage "https://github.com/MervinPraison/PraisonAI"
    -    url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/0.1.2.tar.gz"
    +    url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/0.1.3.tar.gz"
         sha256 "1828fb9227d10f991522c3f24f061943a254b667196b40b1a3e4a54a8d30ce32"  # Replace with actual SHA256 checksum
         license "MIT"
       
    
  • praisonai/ui/realtime.py+20 13 modified
    @@ -6,7 +6,6 @@
     
     from openai import AsyncOpenAI
     import chainlit as cl
    -from chainlit.logger import logger
     from chainlit.input_widget import TextInput
     from chainlit.types import ThreadDict
     
    @@ -16,6 +15,25 @@
     import chainlit.data as cl_data
     from literalai.helper import utc_now
     import json
    +import logging
    +import importlib.util
    +from importlib import import_module
    +from pathlib import Path
    +
    +# Set up logging
    +logger = logging.getLogger(__name__)
    +log_level = os.getenv("LOGLEVEL", "INFO").upper()
    +logger.handlers = []
    +
    +# Set up logging to console
    +console_handler = logging.StreamHandler()
    +console_handler.setLevel(log_level)
    +console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    +console_handler.setFormatter(console_formatter)
    +logger.addHandler(console_handler)
    +
    +# Set the logging level for the logger
    +logger.setLevel(log_level)
     
     # Set up CHAINLIT_AUTH_SECRET
     CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
    @@ -144,19 +162,8 @@ def load_setting(key: str) -> str:
     
     client = AsyncOpenAI()
     
    -# Add these new imports and code
    -import importlib.util
    -import logging
    -from importlib import import_module
    -from pathlib import Path
    -
    -# Set up logging
    -logging.basicConfig(level=logging.INFO)
    -logger = logging.getLogger(__name__)
    -
     # Try to import tools from the root directory
    -root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    -tools_path = os.path.join(root_dir, 'tools.py')
    +tools_path = os.path.join(os.getcwd(), 'tools.py')
     logger.info(f"Tools path: {tools_path}")
     
     def import_tools_from_file(file_path):
    
  • pyproject.toml+1 1 modified
    @@ -1,6 +1,6 @@
     [tool.poetry]
     name = "PraisonAI"
    -version = "0.1.2"
    +version = "0.1.3"
     description = "PraisonAI application combines AutoGen and CrewAI or similar frameworks into a low-code solution for building and managing multi-agent LLM systems, focusing on simplicity, customization, and efficient human-agent collaboration."
     authors = ["Mervin Praison"]
     license = ""
    

Vulnerability mechanics

Root cause

"LabelService methods accept label_id and issue_id without verifying they belong to the workspace used for membership authorization."

Attack vector

An attacker who is a member of any workspace can harvest a foreign workspace's `label_id` and `issue_id` (e.g., from exported dumps, error messages, or activity feeds) and then call any of the five unprotected endpoints using their own `workspace_id` for membership authentication [ref_id=1][ref_id=2]. Because the route's `require_workspace_member(workspace_id)` check passes but the service layer never verifies that the supplied `label_id` or `issue_id` belongs to that workspace, the attacker can rename/delete foreign labels, attach/detach arbitrary labels on foreign issues, and read label assignments across the deployment [ref_id=1][ref_id=2].

Affected code

The vulnerability resides in `src/praisonai-platform/praisonai_platform/services/label_service.py`, lines 35-100, and the route handlers in `src/praisonai-platform/praisonai_platform/api/routes/labels.py`, lines 42-106 [ref_id=1][ref_id=2]. `LabelService.get(label_id)` runs `session.get(IssueLabel, label_id)` with no workspace filter; `update`, `delete`, `add_to_issue`, `remove_from_issue`, and `list_for_issue` all inherit this gap, operating on any label or issue ID in the database regardless of workspace ownership [ref_id=1][ref_id=2].

What the fix does

The suggested fix threads `workspace_id` into every `LabelService` method and adds a workspace predicate to the database query, e.g., changing `get(label_id)` to `get(workspace_id, label_id)` with a `WHERE` clause on both `IssueLabel.id` and `IssueLabel.workspace_id` [ref_id=1][ref_id=2]. The route handlers in `routes/labels.py` must be updated to pass `workspace_id` through every call, so that label and issue IDs that do not belong to the membership-checked workspace return 404, preventing cross-workspace tampering [ref_id=1][ref_id=2].

Preconditions

  • configpraisonai-platform is deployed multi-tenant
  • authAttacker has any workspace membership token
  • inputTarget label_id and issue_id are known or guessable

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.