Skip to content

Commit e4614af

Browse files
1 parent cc8c8e6 commit e4614af

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-943m-6wx2-rc2j",
4+
"modified": "2026-06-01T14:17:04Z",
5+
"published": "2026-06-01T14:17:04Z",
6+
"aliases": [
7+
"CVE-2026-47418"
8+
],
9+
"summary": "praisonai-platform: Project endpoints accept any project_id without workspace ownership check, cross-workspace read/update/delete IDOR",
10+
"details": "## Summary\n\n**Type:** Insecure Direct Object Reference. The project CRUD endpoints (`GET / PATCH / DELETE /workspaces/{workspace_id}/projects/{project_id}` and `GET .../{project_id}/stats`) gate access on `require_workspace_member(workspace_id)` only, then resolve `project_id` through `ProjectService.get(project_id)` / `update(project_id, ...)` / `delete(project_id)` / `get_stats(project_id)`. None of these calls thread `workspace_id` through to constrain the lookup. A user who is a member of any workspace `W1` can read, modify, delete, or read stats for projects that belong to a different workspace `W2`.\n**File:** `src/praisonai-platform/praisonai_platform/services/project_service.py`, lines 47-108; route handlers at `src/praisonai-platform/praisonai_platform/api/routes/projects.py`, lines 51-108.\n**Root cause:** identical to the agent and issue IDORs in this codebase. The route accepts `workspace_id` from URL, uses it solely for the membership gate, then calls `ProjectService.get(project_id)` which is `session.get(Project, project_id)` — a primary-key-only lookup with no `workspace_id` predicate. `update` and `delete` call `self.get(project_id)` first, inheriting the gap. `get_stats` likewise has no workspace check.\n\n## Affected Code\n\n**File 1:** `src/praisonai-platform/praisonai_platform/services/project_service.py`, lines 47-108.\n\n```python\nclass ProjectService:\n ...\n\n async def get(self, project_id: str) -> Optional[Project]:\n \"\"\"Get project by ID.\"\"\"\n return await self._session.get(Project, project_id) # <-- BUG: no workspace_id predicate\n\n async def update(\n self,\n project_id: str,\n ...\n ) -> Optional[Project]:\n project = await self.get(project_id) # <-- inherits the gap\n ...\n\n async def delete(self, project_id: str) -> bool:\n project = await self.get(project_id) # <-- inherits the gap\n ...\n\n async def get_stats(self, project_id: str) -> dict:\n ... # <-- also no workspace check; returns issue counts for any project\n```\n\n**File 2:** `src/praisonai-platform/praisonai_platform/api/routes/projects.py`, lines 51-108.\n\n```python\n@router.get(\"/{project_id}\", response_model=ProjectResponse)\nasync def get_project(\n workspace_id: str,\n project_id: str,\n user: AuthIdentity = Depends(require_workspace_member),\n session: AsyncSession = Depends(get_db),\n):\n svc = ProjectService(session)\n project = await svc.get(project_id) # <-- workspace_id never threaded through\n if project is None:\n raise HTTPException(status_code=404, detail=\"Project not found\")\n return ProjectResponse.model_validate(project)\n\n\n@router.patch(\"/{project_id}\", response_model=ProjectResponse)\nasync def update_project(...):\n svc = ProjectService(session)\n project = await svc.update(project_id, title=body.title, ...) # <-- writes to any project in the DB\n\n@router.delete(\"/{project_id}\", ...)\nasync def delete_project(...):\n deleted = await svc.delete(project_id) # <-- deletes any project in the DB\n\n@router.get(\"/{project_id}/stats\")\nasync def project_stats(...):\n return await svc.get_stats(project_id) # <-- returns stats for any project in the DB\n```\n\n**Why it's wrong:** `workspace_id` from the route is treated as a UI hint (gates \"are you in some workspace W?\") rather than an authoritative predicate (should also gate \"is the project you are addressing actually inside W?\"). The `MemberService` in this same codebase uses a composite `(workspace_id, user_id)` key and demonstrates the safe pattern; the project service simply did not apply it.\n\n## Exploit Chain\n\n1. Attacker registers a workspace `W_attacker` (where they are a member) and harvests a target project UUID `P_T`. Project IDs leak through the activity feed (`act_svc.log` records `entity_id`), issue records (every issue carries `project_id`), webhook payloads, error messages, exported issue dumps, or operator screenshots. State: attacker holds `P_T`.\n2. Attacker authenticates and sends `GET /workspaces/W_attacker/projects/P_T`. `require_workspace_member(W_attacker, attacker)` passes. State: control flow enters `get_project` with `workspace_id=W_attacker, project_id=P_T`.\n3. `ProjectService.get(P_T)` runs `session.get(Project, \"P_T\")`, which is `SELECT * FROM projects WHERE id = 'P_T' LIMIT 1` with no `workspace_id` filter. The row is returned: `title`, `description` (often the project's confidential roadmap), `status`, `lead_type`, `lead_id`, `icon`, `created_at`, `workspace_id` (the foreign workspace's UUID is itself disclosed). State: response body is the JSON-serialised foreign project.\n4. Attacker repeats with `PATCH /workspaces/W_attacker/projects/P_T` and `{\"title\": \"<reset>\", \"description\": \"<wiped>\", \"status\": \"archived\"}`. `update_project` calls `svc.update(P_T, ...)` and mutates the foreign row. State: target project is silently re-titled, re-described, and archived.\n5. Attacker calls `DELETE /workspaces/W_attacker/projects/P_T` to delete the foreign project entirely. State: target project is gone (every issue still referencing it now has a dangling `project_id`).\n6. Attacker calls `GET /workspaces/W_attacker/projects/P_T/stats` to read aggregate issue counts (open/closed/in-progress) for the foreign project — useful for competitive intelligence even when full-issue read is not possible.\n7. Final state: any attacker with one workspace-member token can enumerate, exfiltrate, rewrite, and delete every project in the multi-tenant deployment given the project UUIDs.\n\n## Security Impact\n\n**Severity:** sec-high. CVSS: network attack, low complexity, low privileges, no user interaction, scope unchanged, high confidentiality (project content + cross-workspace metadata via the leaked `workspace_id` field), high integrity (arbitrary writes / deletes), no availability claim (issue rows survive parent-project deletion).\n**Attacker capability:** read, edit, archive, delete, and stats-fingerprint any project in the multi-tenant deployment given the project UUID. Beyond plain content disclosure, the response also includes `workspace_id`, allowing the attacker to map the deployment's workspace topology (which workspaces exist, which projects each owns).\n**Preconditions:** `praisonai-platform` is deployed multi-tenant; the attacker has any membership token; the target project's UUID is known or guessable.\n**Differential:** source-inspection-verified end-to-end. The asymmetry between `ProjectService.get(project_id)` (no workspace check) and `MemberService.get(workspace_id, user_id)` (composite key check) confirms the gap. With the suggested fix below, `ProjectService.get(workspace_id, project_id)` returns `None` for foreign-workspace projects and the route handler returns 404.\n\n## Suggested Fix\n\nSame shape as the companion agent and issue advisories. Make the resource-lookup query include the workspace predicate; treat foreign-workspace rows as 404.\n\n```diff\n--- a/src/praisonai-platform/praisonai_platform/services/project_service.py\n+++ b/src/praisonai-platform/praisonai_platform/services/project_service.py\n@@ -45,9 +45,12 @@ class ProjectService:\n await self._session.flush()\n return project\n\n- async def get(self, project_id: str) -> Optional[Project]:\n- \"\"\"Get project by ID.\"\"\"\n- return await self._session.get(Project, project_id)\n+ async def get(self, workspace_id: str, project_id: str) -> Optional[Project]:\n+ \"\"\"Get project by ID, scoped to a workspace.\"\"\"\n+ stmt = select(Project).where(\n+ Project.id == project_id, Project.workspace_id == workspace_id\n+ )\n+ return (await self._session.execute(stmt)).scalar_one_or_none()\n\n async def update(\n self,\n+ workspace_id: str,\n project_id: str,\n ...\n ) -> Optional[Project]:\n- project = await self.get(project_id)\n+ project = await self.get(workspace_id, project_id)\n\n- async def delete(self, project_id: str) -> bool:\n+ async def delete(self, workspace_id: str, project_id: str) -> bool:\n- project = await self.get(project_id)\n+ project = await self.get(workspace_id, project_id)\n\n- async def get_stats(self, project_id: str) -> dict:\n+ async def get_stats(self, workspace_id: str, project_id: str) -> dict:\n+ # Also constrain the underlying issue counts query by workspace_id.\n```\n\nUpdate the route handlers in `routes/projects.py` to thread `workspace_id` through every call. The same single-key-lookup pattern is filed separately for `AgentService`, `IssueService`, `CommentService`, and `LabelService`.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "praisonai-platform"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "0.1.4"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-943m-6wx2-rc2j"
42+
},
43+
{
44+
"type": "PACKAGE",
45+
"url": "https://github.com/MervinPraison/PraisonAI"
46+
}
47+
],
48+
"database_specific": {
49+
"cwe_ids": [
50+
"CWE-639"
51+
],
52+
"severity": "HIGH",
53+
"github_reviewed": true,
54+
"github_reviewed_at": "2026-06-01T14:17:04Z",
55+
"nvd_published_at": null
56+
}
57+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-q53q-5r4j-5729",
4+
"modified": "2026-06-01T14:15:31Z",
5+
"published": "2026-06-01T14:15:31Z",
6+
"aliases": [
7+
"CVE-2026-47425"
8+
],
9+
"summary": "rattler has an entry-point path traversal in noarch:python install (arbitrary file write)",
10+
"details": "## Summary\n\n`EntryPoint::FromStr` in `rattler_conda_types` performs only `.trim()` on the `command` field before the linker joins it onto the install prefix and writes an executable Python script. A malicious `noarch:python` package can ship an `info/link.json` with an entry-point name containing `..`, `/`, `\\`, or an absolute path; the resulting file is written outside the prefix (or clobbers an existing in-prefix entry-point such as `bin/pip`) with mode `0o775` on Unix and a copied launcher `.exe` on Windows. This affects the default install path of `pixi install`, `rattler-build`, some methods in `py-rattler`, and any other consumer of the `rattler` install crate; no flag or post-link-script opt-in is involved.\n\nResolved in https://github.com/conda/rattler/pull/2445, released in rattler 0.43.2.\n\n## Affected\n\n- Repository: https://github.com/conda/rattler\n- Commit: `a0e61a33da8b9d6de712fab2a879fa9da977e6e3` (HEAD at audit time, 2026-05-13 release)\n- Downstream consumers reached through the same code path: `prefix-dev/pixi` @ `e640477`\n- pixi 0.69.0 and rattler-build 0.65.0 fix this issue\n\n## Researcher\n\nBerkant Koc <me@berkoc.com>\nPGP: 0C588DFD76204987284213EA0AC529C41F8AA5D6",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:U"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "crates.io",
21+
"name": "rattler"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "0.43.2"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/conda/rattler/security/advisories/GHSA-q53q-5r4j-5729"
42+
},
43+
{
44+
"type": "WEB",
45+
"url": "https://github.com/conda/rattler/pull/2445"
46+
},
47+
{
48+
"type": "PACKAGE",
49+
"url": "https://github.com/conda/rattler"
50+
}
51+
],
52+
"database_specific": {
53+
"cwe_ids": [
54+
"CWE-22",
55+
"CWE-73"
56+
],
57+
"severity": "MODERATE",
58+
"github_reviewed": true,
59+
"github_reviewed_at": "2026-06-01T14:15:31Z",
60+
"nvd_published_at": null
61+
}
62+
}

0 commit comments

Comments
 (0)