praisonai-platform: Label endpoints' unchecked label_id/issue_id enable cross-workspace label IDOR (edit, delete, link)
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
- Attacker registers a workspace
W_attacker(member) and harvests a foreign-workspacelabel_idL_Tand a foreign-workspaceissue_idI_T. Both leak vialist_labelsresponses (which include label IDs — but only forW_attacker; for the target the IDs come from issue records that include label associations, activity feeds, exported dumps, error messages). State: attacker holdsL_TandI_T. - Attacker authenticates and sends
PATCH /workspaces/W_attacker/labels/L_Twith{"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. - 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. - Attacker sends
POST /workspaces/W_attacker/issues/I_T/labels/L_T2to attach foreign labelL_T2to foreign issueI_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. - Attacker sends
DELETE /workspaces/W_attacker/issues/I_T/labels/L_legitto strip the legitimate label off the foreign issue. State: triagers can no longer find the issue via label filters. - Attacker sends
GET /workspaces/W_attacker/issues/I_T/labelsto read the current label set on any foreign issue. State: the attacker fingerprints the foreign workspace's triage taxonomy. - 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.
| Package | Affected versions | Patched versions |
|---|---|---|
praisonai-platformPyPI | < 0.1.4 | 0.1.4 |
Affected products
1Patches
18b72cec167ecMerge pull request #168 from MervinPraison/develop
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("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/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
2News mentions
0No linked articles in our index yet.