PraisonAI `deploy --type api` emits a Flask server with authentication disabled by default
Description
Summary
CVE-2026-44338 (GHSA-6rmh-7xcm-cpxj) documents that PraisonAI ships a code-generator (praisonai.deploy.api.generate_api_server_code) that emits a Flask API server with authentication disabled by default. Users who follow the documented quickstart (praisonai deploy --type api) get a server that:
- binds to
0.0.0.0per the recommended sample YAML - exposes
/chatand/agentsendpoints - runs
praisonai.run()on user-supplied JSON input — LLM orchestration with the API key materials present in the process environment - does not require any authentication
The PyPI wheel praisonai==4.6.33 (current @latest) still ships the generator with auth_enabled defaulting to False. The fix shape is opt-in via APIConfig(auth_enabled=True, auth_token=...).
Details
Anchor (file:line:symbol)
- Vulnerable artifact:
praisonai==4.6.33on PyPI. - Defaults:
praisonai/deploy/models.py:29—auth_enabled: bool = Field(default=False, ...);praisonai/deploy/models.py:30—auth_token: Optional[str] = Field(default=None, ...). - Generator:
praisonai/deploy/api.py:40—AUTH_ENABLED = {config.auth_enabled};api.py:41—AUTH_TOKEN = {repr(config.auth_token)};api.py:43-49—def check_auth(): if not AUTH_ENABLED: return True. - CLI entry: documented as
praisonai deploy --type api(vendor README); produces the generator output above with no flag required to suppress the warning, because no warning is emitted.
Vulnerable code (verbatim from installed wheel)
# praisonai/deploy/models.py (praisonai==4.6.33)
class APIConfig(BaseModel):
host: str = Field(default="127.0.0.1", description="Server host")
port: int = Field(default=8005, description="Server port")
cors_enabled: bool = Field(default=True, description="Enable CORS")
auth_enabled: bool = Field(default=False, description="Enable authentication") # line 29
auth_token: Optional[str] = Field(default=None, description="Authentication token") # line 30
# praisonai/deploy/api.py (praisonai==4.6.33)
code = f\'\'\'...
# Authentication
AUTH_ENABLED = {config.auth_enabled} # False by default
AUTH_TOKEN = {repr(config.auth_token)} # None by default
def check_auth():
if not AUTH_ENABLED:
return True # short-circuit, accept all
token = request.headers.get(\'Authorization\', \'\').replace(\'Bearer \', \'\')
return token == AUTH_TOKEN
...
\'\'\'
A default invocation of the deploy command emits a server whose check_auth() short-circuits to True and accepts unauthenticated /chat, /agents POSTs.
PoC
#!/usr/bin/env python3
"""
legend-c420 PoC - PraisonAI 4.6.33 generates Flask API server with auth
disabled by default. Class H sibling of CVE-2026-44338.
Phase 1: reflect on praisonai.deploy.models.APIConfig defaults.
Phase 2: call generate_api_server_code(default config) and assert the
emitted source contains AUTH_ENABLED = False and the
short-circuit return.
Phase 3: re-run with auth_enabled=True, auth_token='s3cret-bearer-value'
and confirm the emitted source flips to the secure shape.
Exit code 0 = PASS = vulnerable defaults confirmed.
"""
import sys, traceback
def phase1_dataclass_defaults():
print("PHASE 1 - praisonai.deploy.models.APIConfig default values")
from praisonai.deploy.models import APIConfig
cfg = APIConfig()
checks = [
("auth_enabled", cfg.auth_enabled, False),
("auth_token", cfg.auth_token, None),
]
for name, observed, expected in checks:
ok = observed == expected
mark = "VULNERABLE" if name in ("auth_enabled","auth_token") and ok else "ok"
print(f" {name:14s} = {observed!r:18s} (expected {expected!r}) [{mark}]")
assert ok
print(" >> APIConfig defaults reproduce the CVE-2026-44338 shape.")
def phase2_default_generator_emits_unauth():
print("PHASE 2 - generate_api_server_code(default config) emits unauth server")
from praisonai.deploy.models import APIConfig
from praisonai.deploy.api import generate_api_server_code
src = generate_api_server_code("agents.yaml", config=APIConfig())
for needle in ["AUTH_ENABLED = False","AUTH_TOKEN = None","if not AUTH_ENABLED:","return True"]:
assert needle in src, f"missing: {needle!r}"
print(f" [FOUND] {needle!r}")
print(" >> Default-config generator emits Flask server with check_auth() short-circuit.")
def phase3_fix_shape_available():
print("PHASE 3 - auth_enabled=True flips to secure shape")
from praisonai.deploy.models import APIConfig
from praisonai.deploy.api import generate_api_server_code
cfg = APIConfig(auth_enabled=True, auth_token="s3cret-bearer-value")
src = generate_api_server_code("agents.yaml", config=cfg)
assert "AUTH_ENABLED = True" in src
assert "AUTH_ENABLED = False" not in src
print(" >> Fix shape works when toggled. Class H confirmed: default is insecure.")
def main():
print("=" * 64)
print("legend-c420 PoC - PraisonAI default-config AUTH_ENABLED=False")
print("=" * 64)
try:
phase1_dataclass_defaults()
phase2_default_generator_emits_unauth()
phase3_fix_shape_available()
except Exception:
traceback.print_exc()
print("FAIL"); sys.exit(2)
print("PASS 3/3 phases. EXIT 0.")
sys.exit(0)
if __name__ == "__main__":
main()
PoC dependencies: praisonai==4.6.33 from PyPI. Tested on Python 3.11.
Run log verdict: PASS 3/3 phases. EXIT 0. — vulnerable-default shape confirmed. auth_enabled=False by default, check_auth() short-circuits to True, fix toggle exists but is opt-in.
Impact
An operator who runs the vendor-documented quickstart (pip install praisonai && praisonai deploy --type api) gets a network-reachable Flask server that invokes praisonai.run() on attacker-supplied JSON with the user's LLM API keys in the process environment. The attacker reaches arbitrary LLM-orchestration (including any tool-use the agents define, which in PraisonAI commonly includes python_repl, bash, file I/O, and HTTP calls), with the host's API-key credit billed to the operator.
- Belief: CVE-2026-44338 was filed and triaged.
- Reality:
praisonai==4.6.33is current@lateston PyPI (2026-05-16). The generator still defaults toauth_enabled=False. - Gap: The CVE acknowledges the fix shape exists. The fix is opt-in. The default-config consumer remains vulnerable.
Parent CVE: CVE-2026-44338 / GHSA-6rmh-7xcm-cpxj
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
PraisonAI's deploy --type api generates a Flask server with authentication disabled by default, exposing LLM orchestration to unauthenticated remote attackers.
Vulnerability
The praisonai deploy --type api command (in praisonai==4.6.33) generates a Flask API server that binds to 0.0.0.0 based on the recommended sample YAML, with authentication disabled by default (auth_enabled: False in praisonai/deploy/models.py:29) and no warning emitted [1][2]. The generated server exposes /chat and /agents endpoints, and runs praisonai.run() on user-supplied JSON input, with API keys present in the process environment [3].
Exploitation
An attacker who can reach the server (network access to the bound 0.0.0.0 address) sends a POST request to /chat with arbitrary JSON containing a message key; no authentication token is required because AUTH_ENABLED defaults to False and check_auth() immediately returns True [1][3]. The attacker can also enumerate agents via the unauthenticated GET /agents endpoint [3].
Impact
Successful exploitation allows remote execution of the configured agents.yaml workflow on the server, including LLM orchestration with access to all API keys loaded in the process environment [1][3]. Depending on the agents' capabilities, this can lead to sensitive data disclosure, unauthorized AI model usage, or further compromise of connected services. No authentication is required, making the attack trivially exploitable by anyone with network connectivity to the server.
Mitigation
As of the latest PyPI release (praisonai==4.6.33), no patched version has been published; the fix requires opt-in configuration: set APIConfig(auth_enabled=True, auth_token=...) or use the newer serve agents command which binds to 127.0.0.1 by default and supports --api-key [1][3]. Users should also ensure the server is not exposed to untrusted networks (e.g., bind to 127.0.0.1 instead of 0.0.0.0) until a fix is provided.
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 |
|---|---|---|
PraisonAIPyPI | < 4.6.40 | 4.6.40 |
Affected products
2Patches
52f8162207fcbsecurity: fix workspace scoping gaps in issue service
2 files changed · +10 −7
SECURITY_TRIAGE.md+3 −4 modified@@ -10,14 +10,13 @@ | GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions | | GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote | | GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard | -| GHSA-xwq8, 7p8g, c2m8, 8g2p, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial | +| GHSA-xwq8, 7p8g, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial | | GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion | | GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening | -| GHSA-6xj3-927j-6pqw | fixed batch 2 | deploy.py bleach sanitize | | GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True | | GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate | -| GHSA-gv23, h8q5, 6h6v, h37g, 6h6v-7vxx | fixed batch 3 | service-layer workspace_id on get/update/delete | -| GHSA-h37g-4h4p-9x97, c2m8, 8g2p | fixed batch 3 | only owner assigns admin/owner | +| GHSA-gv23-xxxx-xxxx, GHSA-h8q5-cp56-rr65, GHSA-6h6v-xxxx-xxxx, GHSA-h37g-4h4p-9x97 | fixed batch 3 | service-layer workspace_id on get/update/delete | +| GHSA-c2m8-xxxx-xxxx, GHSA-8g2p-xxxx-xxxx | fixed batch 3 | only owner assigns admin/owner | | GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) | | GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo | | GHSA-gmjg, 9q28 | published | prior release |
src/praisonai-platform/praisonai_platform/services/issue_service.py+7 −3 modified@@ -144,17 +144,21 @@ async def assign( issue_id: str, assignee_type: str, assignee_id: str, + *, + workspace_id: Optional[str] = None, ) -> Optional[Issue]: """Assign an issue to a member or agent.""" if assignee_type not in VALID_ASSIGNEE_TYPES: raise ValueError(f"Invalid assignee_type: {assignee_type}") return await self.update( - issue_id, assignee_type=assignee_type, assignee_id=assignee_id + issue_id, workspace_id=workspace_id, assignee_type=assignee_type, assignee_id=assignee_id ) - async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]: + async def transition( + self, issue_id: str, new_status: str, *, workspace_id: Optional[str] = None + ) -> Optional[Issue]: """Transition an issue to a new status.""" - return await self.update(issue_id, status=new_status) + return await self.update(issue_id, workspace_id=workspace_id, status=new_status) async def delete( self, issue_id: str, *, workspace_id: Optional[str] = None
516d8b6d88dfrefactor: enforce workspace scope in platform services
11 files changed · +120 −52
SECURITY_TRIAGE.md+6 −1 modified@@ -16,7 +16,12 @@ | GHSA-6xj3-927j-6pqw | fixed batch 2 | deploy.py bleach sanitize | | GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True | | GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate | -| GHSA-gv23, h8q5, 6h6v, h37g | partial/defer | broader platform audit | +| GHSA-gv23, h8q5, 6h6v, h37g, 6h6v-7vxx | fixed batch 3 | service-layer workspace_id on get/update/delete | +| GHSA-h37g-4h4p-9x97, c2m8, 8g2p | fixed batch 3 | only owner assigns admin/owner | +| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) | +| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo | | GHSA-gmjg, 9q28 | published | prior release | +**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.** + Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
src/praisonai-platform/praisonai_platform/api/routes/agents.py+4 −9 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member +from ..deps import get_db, require_workspace_member from ..schemas import AgentCreate, AgentResponse, AgentUpdate from ...services.agent_service import AgentService @@ -58,10 +58,9 @@ async def get_agent( session: AsyncSession = Depends(get_db), ): svc = AgentService(session) - agent = await svc.get(agent_id) + agent = await svc.get(agent_id, workspace_id=workspace_id) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") - ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") return AgentResponse.model_validate(agent) @@ -76,6 +75,7 @@ async def update_agent( svc = AgentService(session) agent = await svc.update( agent_id, + workspace_id=workspace_id, name=body.name, status=body.status, instructions=body.instructions, @@ -85,7 +85,6 @@ async def update_agent( ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") - ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") return AgentResponse.model_validate(agent) @@ -97,10 +96,6 @@ async def delete_agent( session: AsyncSession = Depends(get_db), ): svc = AgentService(session) - agent = await svc.get(agent_id) - if agent is None: - raise HTTPException(status_code=404, detail="Agent not found") - ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") - deleted = await svc.delete(agent_id) + deleted = await svc.delete(agent_id, workspace_id=workspace_id) if not deleted: raise HTTPException(status_code=404, detail="Agent not found")
src/praisonai-platform/praisonai_platform/api/routes/dependencies.py+2 −0 modified@@ -62,6 +62,8 @@ async def delete_dependency( dep = await svc.get(dep_id) if dep is None: raise HTTPException(status_code=404, detail="Dependency not found") + if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id: + raise HTTPException(status_code=404, detail="Dependency not found") deleted = await svc.delete(dep_id) if not deleted: raise HTTPException(status_code=404, detail="Dependency not found")
src/praisonai-platform/praisonai_platform/api/routes/issues.py+3 −8 modified@@ -87,10 +87,9 @@ async def get_issue( session: AsyncSession = Depends(get_db), ): svc = IssueService(session) - issue = await svc.get(issue_id) + issue = await svc.get(issue_id, workspace_id=workspace_id) if issue is None: raise HTTPException(status_code=404, detail="Issue not found") - ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") return IssueResponse.model_validate(issue) @@ -105,6 +104,7 @@ async def update_issue( svc = IssueService(session) issue = await svc.update( issue_id, + workspace_id=workspace_id, title=body.title, description=body.description, status=body.status, @@ -115,7 +115,6 @@ async def update_issue( ) if issue is None: raise HTTPException(status_code=404, detail="Issue not found") - ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") act_svc = ActivityService(session) await act_svc.log( workspace_id, "issue.updated", "issue", issue.id, @@ -134,11 +133,7 @@ async def delete_issue( session: AsyncSession = Depends(get_db), ): svc = IssueService(session) - issue = await svc.get(issue_id) - if issue is None: - raise HTTPException(status_code=404, detail="Issue not found") - ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") - deleted = await svc.delete(issue_id) + deleted = await svc.delete(issue_id, workspace_id=workspace_id) if not deleted: raise HTTPException(status_code=404, detail="Issue not found")
src/praisonai-platform/praisonai_platform/api/routes/projects.py+5 −11 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member +from ..deps import get_db, require_workspace_member from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate from ...services.project_service import ProjectService @@ -56,10 +56,9 @@ async def get_project( session: AsyncSession = Depends(get_db), ): svc = ProjectService(session) - project = await svc.get(project_id) + project = await svc.get(project_id, workspace_id=workspace_id) if project is None: raise HTTPException(status_code=404, detail="Project not found") - ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") return ProjectResponse.model_validate(project) @@ -74,6 +73,7 @@ async def update_project( svc = ProjectService(session) project = await svc.update( project_id, + workspace_id=workspace_id, title=body.title, description=body.description, status=body.status, @@ -82,7 +82,6 @@ async def update_project( ) if project is None: raise HTTPException(status_code=404, detail="Project not found") - ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") return ProjectResponse.model_validate(project) @@ -94,11 +93,7 @@ async def delete_project( session: AsyncSession = Depends(get_db), ): svc = ProjectService(session) - project = await svc.get(project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") - ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") - deleted = await svc.delete(project_id) + deleted = await svc.delete(project_id, workspace_id=workspace_id) if not deleted: raise HTTPException(status_code=404, detail="Project not found") @@ -111,8 +106,7 @@ async def project_stats( session: AsyncSession = Depends(get_db), ): svc = ProjectService(session) - project = await svc.get(project_id) + project = await svc.get(project_id, workspace_id=workspace_id) if project is None: raise HTTPException(status_code=404, detail="Project not found") - ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") return await svc.get_stats(project_id)
src/praisonai-platform/praisonai_platform/api/routes/workspaces.py+4 −4 modified@@ -103,11 +103,11 @@ async def add_member( session: AsyncSession = Depends(get_db), ): member_svc = MemberService(session) - if body.role == "owner": + if body.role in ("owner", "admin"): if not await member_svc.has_role(workspace_id, user.id, "owner"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Only owners can add another owner", + detail="Only owners can add admin or owner roles", ) member = await member_svc.add(workspace_id, body.user_id, body.role) return MemberResponse.model_validate(member) @@ -141,12 +141,12 @@ async def update_member_role( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot change your own role", ) - if body.role == "owner" and not await member_svc.has_role( + if body.role in ("owner", "admin") and not await member_svc.has_role( workspace_id, user.id, "owner" ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Only owners can assign the owner role", + detail="Only owners can assign admin or owner roles", ) if target.role == "owner" and not await member_svc.has_role( workspace_id, user.id, "owner"
src/praisonai-platform/praisonai_platform/__main__.py+7 −1 modified@@ -7,12 +7,18 @@ """ import argparse +import os import sys def main() -> None: + default_host = os.environ.get("PLATFORM_HOST", "127.0.0.1") parser = argparse.ArgumentParser(description="PraisonAI Platform Server") - parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)") + parser.add_argument( + "--host", + default=default_host, + help="Bind host (default: 127.0.0.1, or PLATFORM_HOST env)", + ) parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)") parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development") args = parser.parse_args()
src/praisonai-platform/praisonai_platform/services/agent_service.py+17 −6 modified@@ -50,9 +50,16 @@ async def create( await self._session.flush() return agent - async def get(self, agent_id: str) -> Optional[Agent]: - """Get agent by ID.""" - return await self._session.get(Agent, agent_id) + async def get( + self, agent_id: str, *, workspace_id: Optional[str] = None + ) -> Optional[Agent]: + """Get agent by ID, optionally scoped to a workspace.""" + agent = await self._session.get(Agent, agent_id) + if agent is None: + return None + if workspace_id is not None and agent.workspace_id != workspace_id: + return None + return agent async def list_for_workspace( self, @@ -72,6 +79,8 @@ async def list_for_workspace( async def update( self, agent_id: str, + *, + workspace_id: Optional[str] = None, name: Optional[str] = None, status: Optional[str] = None, instructions: Optional[str] = None, @@ -80,7 +89,7 @@ async def update( max_concurrent_tasks: Optional[int] = None, ) -> Optional[Agent]: """Update agent fields.""" - agent = await self.get(agent_id) + agent = await self.get(agent_id, workspace_id=workspace_id) if agent is None: return None if name is not None: @@ -102,9 +111,11 @@ async def update( await self._session.flush() return agent - async def delete(self, agent_id: str) -> bool: + async def delete( + self, agent_id: str, *, workspace_id: Optional[str] = None + ) -> bool: """Delete an agent.""" - agent = await self.get(agent_id) + agent = await self.get(agent_id, workspace_id=workspace_id) if agent is None: return False await self._session.delete(agent)
src/praisonai-platform/praisonai_platform/services/issue_service.py+17 −6 modified@@ -69,9 +69,16 @@ async def create( await self._session.flush() return issue - async def get(self, issue_id: str) -> Optional[Issue]: - """Get issue by ID.""" - return await self._session.get(Issue, issue_id) + async def get( + self, issue_id: str, *, workspace_id: Optional[str] = None + ) -> Optional[Issue]: + """Get issue by ID, optionally scoped to a workspace.""" + issue = await self._session.get(Issue, issue_id) + if issue is None: + return None + if workspace_id is not None and issue.workspace_id != workspace_id: + return None + return issue async def list_for_workspace( self, @@ -97,6 +104,8 @@ async def list_for_workspace( async def update( self, issue_id: str, + *, + workspace_id: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, status: Optional[str] = None, @@ -106,7 +115,7 @@ async def update( project_id: Optional[str] = None, ) -> Optional[Issue]: """Update issue fields.""" - issue = await self.get(issue_id) + issue = await self.get(issue_id, workspace_id=workspace_id) if issue is None: return None if title is not None: @@ -147,9 +156,11 @@ async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]: """Transition an issue to a new status.""" return await self.update(issue_id, status=new_status) - async def delete(self, issue_id: str) -> bool: + async def delete( + self, issue_id: str, *, workspace_id: Optional[str] = None + ) -> bool: """Delete an issue.""" - issue = await self.get(issue_id) + issue = await self.get(issue_id, workspace_id=workspace_id) if issue is None: return False await self._session.delete(issue)
src/praisonai-platform/praisonai_platform/services/project_service.py+17 −6 modified@@ -44,9 +44,16 @@ async def create( await self._session.flush() return project - async def get(self, project_id: str) -> Optional[Project]: - """Get project by ID.""" - return await self._session.get(Project, project_id) + async def get( + self, project_id: str, *, workspace_id: Optional[str] = None + ) -> Optional[Project]: + """Get project by ID, optionally scoped to a workspace.""" + project = await self._session.get(Project, project_id) + if project is None: + return None + if workspace_id is not None and project.workspace_id != workspace_id: + return None + return project async def list_for_workspace( self, @@ -62,14 +69,16 @@ async def list_for_workspace( async def update( self, project_id: str, + *, + workspace_id: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, status: Optional[str] = None, lead_type: Optional[str] = None, lead_id: Optional[str] = None, ) -> Optional[Project]: """Update project fields.""" - project = await self.get(project_id) + project = await self.get(project_id, workspace_id=workspace_id) if project is None: return None if title is not None: @@ -85,9 +94,11 @@ async def update( await self._session.flush() return project - async def delete(self, project_id: str) -> bool: + async def delete( + self, project_id: str, *, workspace_id: Optional[str] = None + ) -> bool: """Delete a project.""" - project = await self.get(project_id) + project = await self.get(project_id, workspace_id=workspace_id) if project is None: return False await self._session.delete(project)
src/praisonai-platform/tests/test_service_workspace_scope.py+38 −0 added@@ -0,0 +1,38 @@ +"""Service-layer workspace scoping for issues and projects.""" + +from __future__ import annotations + +import pytest + +from praisonai_platform.services.auth_service import AuthService +from praisonai_platform.services.issue_service import IssueService +from praisonai_platform.services.project_service import ProjectService +from praisonai_platform.services.workspace_service import WorkspaceService + + +@pytest.mark.asyncio +async def test_issue_get_rejects_wrong_workspace(session): + auth = AuthService(session) + user, _ = await auth.register("scope@test.com", "pass") + ws_a = await WorkspaceService(session).create("A", user.id) + ws_b = await WorkspaceService(session).create("B", user.id) + issue_svc = IssueService(session) + issue = await issue_svc.create( + workspace_id=ws_a.id, + title="secret", + creator_id=user.id, + ) + assert await issue_svc.get(issue.id, workspace_id=ws_a.id) is not None + assert await issue_svc.get(issue.id, workspace_id=ws_b.id) is None + + +@pytest.mark.asyncio +async def test_project_delete_scoped_to_workspace(session): + auth = AuthService(session) + user, _ = await auth.register("proj_scope@test.com", "pass") + ws_a = await WorkspaceService(session).create("PA", user.id) + ws_b = await WorkspaceService(session).create("PB", user.id) + proj_svc = ProjectService(session) + project = await proj_svc.create(workspace_id=ws_a.id, title="p1") + assert await proj_svc.delete(project.id, workspace_id=ws_b.id) is False + assert await proj_svc.delete(project.id, workspace_id=ws_a.id) is True
3ea837661036refactor: harden platform scoping and deploy output sanitisation
13 files changed · +212 −19
examples/python/managed-agents/provider/local_advanced.py+31 −4 modified@@ -23,12 +23,39 @@ result = agent.start("What is 123 + 456?", stream=True) # ── 3. Custom tool ── -def handle_calculator(tool_name, tool_input): - expr = tool_input.get("expression", "0") +def _safe_calc(expr: str) -> str: + import ast + allowed = set("0123456789+-*/.() ") + if not all(c in allowed for c in expr): + return "error" try: - val = eval(expr, {"__builtins__": {}}) + tree = ast.parse(expr, mode="eval") + for node in ast.walk(tree): + if not isinstance( + node, + ( + ast.Expression, + ast.BinOp, + ast.UnaryOp, + ast.Constant, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.USub, + ast.UAdd, + ), + ): + return "error" + val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}}) + return str(val) except Exception: - val = "error" + return "error" + + +def handle_calculator(tool_name, tool_input): + expr = tool_input.get("expression", "0") + val = _safe_calc(str(expr)) print(f" [Calculator: {expr} = {val}]") return str(val)
examples/serve/mcp_http_server.py+26 −5 modified@@ -44,12 +44,33 @@ def search(query: str) -> str: def calculate(expression: str) -> str: """Calculate a math expression safely.""" - try: - # Safe eval for basic math - allowed = set("0123456789+-*/.() ") - if all(c in allowed for c in expression): - return str(eval(expression)) + import ast + + allowed = set("0123456789+-*/.() ") + if not all(c in allowed for c in expression): return "Error: Invalid expression" + try: + tree = ast.parse(expression, mode="eval") + for node in ast.walk(tree): + if not isinstance( + node, + ( + ast.Expression, + ast.BinOp, + ast.UnaryOp, + ast.Constant, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.USub, + ast.UAdd, + ), + ): + return "Error: Invalid expression" + return str( + eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}}) + ) except Exception as e: return f"Error: {e}"
SECURITY_TRIAGE.md+22 −0 added@@ -0,0 +1,22 @@ +# Security advisory triage (maintainer) + +| GHSA | Status after batch 1+2 | Notes | +|------|------------------------|-------| +| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST | +| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF | +| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths | +| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth | +| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd | +| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions | +| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote | +| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard | +| GHSA-xwq8, 7p8g, c2m8, 8g2p, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial | +| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion | +| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening | +| GHSA-6xj3-927j-6pqw | fixed batch 2 | deploy.py bleach sanitize | +| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True | +| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate | +| GHSA-gv23, h8q5, 6h6v, h37g | partial/defer | broader platform audit | +| GHSA-gmjg, 9q28 | published | prior release | + +Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
src/praisonai-platform/praisonai_platform/api/deps.py+18 −0 modified@@ -107,3 +107,21 @@ def ensure_resource_in_workspace( status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found", ) + + +async def require_issue_in_workspace( + workspace_id: str, + issue_id: str, + session: AsyncSession, +): + """Load an issue and verify it belongs to the URL workspace.""" + from ..db.models import Issue + + issue = await session.get(Issue, issue_id) + if issue is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Issue not found", + ) + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") + return issue
src/praisonai-platform/praisonai_platform/api/routes/activity.py+2 −1 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member +from ..deps import get_db, require_issue_in_workspace, require_workspace_member from ..schemas import ActivityLogResponse from ...services.activity_service import ActivityService @@ -38,6 +38,7 @@ async def list_issue_activity( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + await require_issue_in_workspace(workspace_id, issue_id, session) svc = ActivityService(session) logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset) return [ActivityLogResponse.model_validate(log) for log in logs]
src/praisonai-platform/praisonai_platform/api/routes/dependencies.py+11 −2 modified@@ -9,9 +9,9 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member -from ..schemas import DependencyCreate, DependencyResponse +from ..deps import get_db, require_issue_in_workspace, require_workspace_member from ...services.dependency_service import DependencyService +from ..schemas import DependencyCreate, DependencyResponse router = APIRouter( prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies", @@ -27,6 +27,10 @@ async def create_dependency( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + await require_issue_in_workspace(workspace_id, issue_id, session) + await require_issue_in_workspace( + workspace_id, body.depends_on_issue_id, session + ) svc = DependencyService(session) dep = await svc.create(issue_id, body.depends_on_issue_id, body.type) return DependencyResponse.model_validate(dep) @@ -39,6 +43,7 @@ async def list_dependencies( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + await require_issue_in_workspace(workspace_id, issue_id, session) svc = DependencyService(session) deps = await svc.list_for_issue(issue_id) return [DependencyResponse.model_validate(d) for d in deps] @@ -52,7 +57,11 @@ async def delete_dependency( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + await require_issue_in_workspace(workspace_id, issue_id, session) svc = DependencyService(session) + dep = await svc.get(dep_id) + if dep is None: + raise HTTPException(status_code=404, detail="Dependency not found") deleted = await svc.delete(dep_id) if not deleted: raise HTTPException(status_code=404, detail="Dependency not found")
src/praisonai-platform/praisonai_platform/api/routes/labels.py+25 −1 modified@@ -9,7 +9,12 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member +from ..deps import ( + ensure_resource_in_workspace, + get_db, + require_issue_in_workspace, + require_workspace_member, +) from ..schemas import LabelCreate, LabelResponse, LabelUpdate from ...services.label_service import LabelService @@ -48,6 +53,10 @@ async def update_label( session: AsyncSession = Depends(get_db), ): svc = LabelService(session) + label = await svc.get(label_id) + if label is None: + raise HTTPException(status_code=404, detail="Label not found") + ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label") label = await svc.update(label_id, body.name, body.color) if label is None: raise HTTPException(status_code=404, detail="Label not found") @@ -62,6 +71,10 @@ async def delete_label( session: AsyncSession = Depends(get_db), ): svc = LabelService(session) + label = await svc.get(label_id) + if label is None: + raise HTTPException(status_code=404, detail="Label not found") + ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label") deleted = await svc.delete(label_id) if not deleted: raise HTTPException(status_code=404, detail="Label not found") @@ -78,7 +91,12 @@ async def add_label_to_issue( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + await require_issue_in_workspace(workspace_id, issue_id, session) svc = LabelService(session) + label = await svc.get(label_id) + if label is None: + raise HTTPException(status_code=404, detail="Label not found") + ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label") await svc.add_to_issue(issue_id, label_id) @@ -90,7 +108,12 @@ async def remove_label_from_issue( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + await require_issue_in_workspace(workspace_id, issue_id, session) svc = LabelService(session) + label = await svc.get(label_id) + if label is None: + raise HTTPException(status_code=404, detail="Label not found") + ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label") await svc.remove_from_issue(issue_id, label_id) @@ -101,6 +124,7 @@ async def list_issue_labels( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + await require_issue_in_workspace(workspace_id, issue_id, session) svc = LabelService(session) labels = await svc.list_for_issue(issue_id) return [LabelResponse.model_validate(l) for l in labels]
src/praisonai-platform/praisonai_platform/api/routes/projects.py+11 −1 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate from ...services.project_service import ProjectService @@ -59,6 +59,7 @@ async def get_project( project = await svc.get(project_id) if project is None: raise HTTPException(status_code=404, detail="Project not found") + ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") return ProjectResponse.model_validate(project) @@ -81,6 +82,7 @@ async def update_project( ) if project is None: raise HTTPException(status_code=404, detail="Project not found") + ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") return ProjectResponse.model_validate(project) @@ -92,6 +94,10 @@ async def delete_project( session: AsyncSession = Depends(get_db), ): svc = ProjectService(session) + project = await svc.get(project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") deleted = await svc.delete(project_id) if not deleted: raise HTTPException(status_code=404, detail="Project not found") @@ -105,4 +111,8 @@ async def project_stats( session: AsyncSession = Depends(get_db), ): svc = ProjectService(session) + project = await svc.get(project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + ensure_resource_in_workspace(project.workspace_id, workspace_id, label="Project") return await svc.get_stats(project_id)
src/praisonai-platform/praisonai_platform/services/workspace_service.py+5 −2 modified@@ -10,7 +10,7 @@ import re from typing import Optional -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from ..db.models import Member, Workspace @@ -98,10 +98,13 @@ async def update( return ws async def delete(self, workspace_id: str) -> bool: - """Delete a workspace.""" + """Delete a workspace and its memberships.""" ws = await self.get(workspace_id) if ws is None: return False + await self._session.execute( + delete(Member).where(Member.workspace_id == workspace_id) + ) await self._session.delete(ws) await self._session.flush() return True
src/praisonai-platform/tests/test_resource_idor.py+20 −0 added@@ -0,0 +1,20 @@ +"""Cross-workspace IDOR guards for platform resources.""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from praisonai_platform.api.deps import ensure_resource_in_workspace + + +def test_project_workspace_mismatch(): + with pytest.raises(HTTPException) as exc: + ensure_resource_in_workspace("ws-other", "ws-mine", label="Project") + assert exc.value.status_code == 404 + + +def test_label_workspace_mismatch(): + with pytest.raises(HTTPException) as exc: + ensure_resource_in_workspace("ws-other", "ws-mine", label="Label") + assert exc.value.status_code == 404
src/praisonai/praisonai/deploy.py+5 −3 modified@@ -76,16 +76,18 @@ def create_api_file(self): with open("api.py", "w") as file: file.write("from flask import Flask\n") file.write("from praisonai import PraisonAI\n") - file.write("import markdown\n\n") + file.write("import markdown\n") + file.write("import bleach\n\n") file.write("app = Flask(__name__)\n\n") file.write("def basic():\n") file.write(" praisonai = PraisonAI(agent_file=\"agents.yaml\")\n") file.write(" return praisonai.run()\n\n") file.write("@app.route('/')\n") file.write("def home():\n") file.write(" output = basic()\n") - file.write(" html_output = markdown.markdown(output)\n") - file.write(" return f'<html><body>{html_output}</body></html>'\n\n") + file.write(" rendered = markdown.markdown(str(output))\n") + file.write(" safe_html = bleach.clean(rendered, tags=bleach.sanitizer.ALLOWED_TAGS, attributes=bleach.sanitizer.ALLOWED_ATTRIBUTES)\n") + file.write(" return f'<html><body>{safe_html}</body></html>'\n\n") file.write("if __name__ == \"__main__\":\n") file.write(" import os\n") file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
src/praisonai/tests/unit/deploy/test_api_auth_default.py+19 −0 added@@ -0,0 +1,19 @@ +"""Generated deploy API servers must enable auth by default.""" + +from praisonai.deploy.api import generate_api_server_code +from praisonai.deploy.models import APIConfig + + +def test_generate_api_server_auth_enabled_by_default(): + code = generate_api_server_code("agents.yaml", APIConfig()) + assert "AUTH_ENABLED" in code + assert "'enabled'" in code or '"enabled"' in code + assert "check_auth" in code + assert "compare_digest" in code + + +def test_generate_api_server_respects_disabled_config(): + code = generate_api_server_code( + "agents.yaml", APIConfig(auth_enabled=False) + ) + assert "'disabled'" in code or '"disabled"' in code
src/praisonai/tests/unit/test_agents_generator_safe_loader.py+17 −0 added@@ -0,0 +1,17 @@ +"""load_tools_from_module must use gated safe loader.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +def test_load_tools_from_module_returns_empty_when_blocked(): + from praisonai.agents_generator import AgentsGenerator + + gen = object.__new__(AgentsGenerator) + with patch( + "praisonai._safe_loader.load_user_module", return_value=None + ): + assert gen.load_tools_from_module("/tmp/evil_tools.py") == {}
179cab02dbecrefactor: harden input validation and access controls
19 files changed · +425 −100
.github/workflows/claude.yml+5 −2 modified@@ -181,12 +181,15 @@ jobs: - name: Fetch PR branch and Setup Remote if: github.event.issue.pull_request + env: + PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }} + IS_FORK: ${{ steps.check_fork.outputs.is_fork }} run: | # Fetch PR head from base repo and put it into local branch - git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }} + git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}" # If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>` - if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then + if [ "${IS_FORK}" = "true" ]; then git remote set-url origin file://$(pwd) fi
src/praisonai-agents/praisonaiagents/tools/mentions.py+5 −0 modified@@ -268,6 +268,11 @@ def _process_rule_mention(self, rule_name: str) -> Optional[str]: def _process_url_mention(self, url: str) -> Optional[str]: """Process @url:https://... mention.""" try: + from praisonaiagents.tools.spider_tools import SpiderTools + + if not SpiderTools()._validate_url(url): + return f"# URL: {url}\n[Blocked: URL is not allowed]" + import urllib.request req = urllib.request.Request(
src/praisonai-agents/praisonaiagents/tools/python_tools.py+55 −48 modified@@ -36,6 +36,30 @@ def _safe_getattr(obj, name, *default): return getattr(obj, name, *default) if default else getattr(obj, name) +_SANDBOX_BLOCKED_ATTRS = frozenset({ + '__subclasses__', '__bases__', '__mro__', '__globals__', + '__code__', '__class__', '__dict__', '__builtins__', + '__import__', '__loader__', '__spec__', '__init_subclass__', + '__set_name__', '__reduce__', '__reduce_ex__', + '__traceback__', '__qualname__', '__module__', + '__wrapped__', '__closure__', '__annotations__', + '__self__', # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh) + # Frame/code object introspection + 'gi_frame', 'gi_code', 'cr_frame', 'cr_code', + 'ag_frame', 'ag_code', 'tb_frame', 'tb_next', + 'f_globals', 'f_locals', 'f_builtins', 'f_code', + 'co_consts', 'co_names', + '__getattribute__', '__getattr__', '__setattr__', '__delattr__', + '__dir__', '__get__', '__set__', '__delete__', +}) + +_SANDBOX_BLOCKED_CALLS = frozenset({ + 'exec', 'eval', 'compile', '__import__', + 'open', 'input', 'breakpoint', + 'setattr', 'delattr', 'dir', 'vars', +}) + + def _validate_code_ast(code: str): """Validate code using AST — catches attacks that bypass text checks. @@ -48,52 +72,33 @@ def _validate_code_ast(code: str): except SyntaxError: return None # let compile() handle syntax errors later - # Dangerous dunder attributes attackers use for sandbox escape - _blocked_attrs = frozenset({ - '__subclasses__', '__bases__', '__mro__', '__globals__', - '__code__', '__class__', '__dict__', '__builtins__', - '__import__', '__loader__', '__spec__', '__init_subclass__', - '__set_name__', '__reduce__', '__reduce_ex__', - '__traceback__', '__qualname__', '__module__', - '__wrapped__', '__closure__', '__annotations__', - # Frame/code object introspection - 'gi_frame', 'gi_code', 'cr_frame', 'cr_code', - 'ag_frame', 'ag_code', 'tb_frame', 'tb_next', - 'f_globals', 'f_locals', 'f_builtins', 'f_code', - 'co_consts', 'co_names', - '__getattribute__', '__getattr__', '__setattr__', '__delattr__', - '__dir__', '__get__', '__set__', '__delete__', - }) - for node in ast.walk(tree): # Block import statements if isinstance(node, (ast.Import, ast.ImportFrom)): return f"Import statements are not allowed" # Block attribute access to dangerous dunders if isinstance(node, ast.Attribute): - if node.attr in _blocked_attrs: + if node.attr in _SANDBOX_BLOCKED_ATTRS: return ( f"Access to attribute '{node.attr}' is restricted" ) - # Block calls to dangerous builtins by name + # Block calls to dangerous builtins (bare name or attribute access) if isinstance(node, ast.Call): func = node.func - if isinstance(func, ast.Name) and func.id in ( - 'exec', 'eval', 'compile', '__import__', - 'open', 'input', 'breakpoint', - 'setattr', 'delattr', 'dir', - ): + if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS: return f"Call to '{func.id}' is not allowed" + if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS: + return f"Call to '{func.attr}' is not allowed" # Block dangerous constants (strings containing dunders) # Fallback for Python 3.7 ast.Str if isinstance(node, ast.Constant) and isinstance(node.value, str): - if any(attr in node.value for attr in _blocked_attrs): + if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS): return f"String constant contains restricted attribute name" elif type(node).__name__ == 'Str': - if any(attr in getattr(node, 's', '') for attr in _blocked_attrs): + if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS): return f"String constant contains restricted attribute name" return None @@ -112,7 +117,16 @@ def _execute_code_sandboxed( """ if limits is None: limits = ResourceLimits.minimal() - + + ast_error = _validate_code_ast(code) + if ast_error: + return { + 'result': None, + 'stdout': '', + 'stderr': f'Security Error: {ast_error}', + 'success': False, + } + try: # Create temporary file for the code with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: @@ -151,21 +165,8 @@ def safe_execute(): }} # Block dangerous patterns - blocked_attrs = {{ - '__subclasses__', '__bases__', '__mro__', '__globals__', - '__code__', '__class__', '__dict__', '__builtins__', - '__import__', '__loader__', '__spec__', '__init_subclass__', - '__set_name__', '__reduce__', '__reduce_ex__', - '__traceback__', '__qualname__', '__module__', - '__wrapped__', '__closure__', '__annotations__', - # Frame/code object introspection - 'gi_frame', 'gi_code', 'cr_frame', 'cr_code', - 'ag_frame', 'ag_code', 'tb_frame', 'tb_next', - 'f_globals', 'f_locals', 'f_builtins', 'f_code', - 'co_consts', 'co_names', - '__getattribute__', '__getattr__', '__setattr__', '__delattr__', - '__dir__', '__get__', '__set__', '__delete__', - }} + blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r} + blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r} for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): @@ -182,14 +183,20 @@ def safe_execute(): "stderr": f"Access to attribute '{{node.attr}}' is restricted", "success": False }} - if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): - if node.func.id in ('exec', 'eval', 'compile', '__import__', - 'open', 'input', 'breakpoint', - 'setattr', 'delattr', 'dir'): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id in blocked_calls: + return {{ + "result": None, + "stdout": "", + "stderr": f"Call to '{{func.id}}' is not allowed", + "success": False + }} + if isinstance(func, ast.Attribute) and func.attr in blocked_calls: return {{ "result": None, "stdout": "", - "stderr": f"Call to '{{node.func.id}}' is not allowed", + "stderr": f"Call to '{{func.attr}}' is not allowed", "success": False }} if isinstance(node, ast.Constant) and isinstance(node.value, str): @@ -481,7 +488,7 @@ def _execute_code_direct( '__import__', 'import ', 'from ', 'exec', 'eval', 'compile', 'open(', 'file(', 'input(', 'raw_input', '__subclasses__', '__bases__', '__globals__', '__code__', - '__class__', 'globals(', 'locals(', 'vars(' + '__class__', '__self__', 'globals(', 'locals(', 'vars(' ] code_lower = code.lower()
src/praisonai-agents/praisonaiagents/tools/spider_tools.py+46 −24 modified@@ -11,6 +11,8 @@ """ import logging +import ipaddress +import socket from typing import List, Dict, Union, Optional, Any from importlib import util import json @@ -20,6 +22,48 @@ import hashlib import time + +def _host_is_blocked(hostname: str) -> bool: + """Return True when hostname resolves to loopback/private/internal targets.""" + if not hostname: + return True + host = hostname.lower().rstrip(".") + if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"): + return True + if host in ("169.254.169.254", "metadata.google.internal"): + return True + if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")): + return True + + def _ip_blocked(ip: ipaddress._BaseAddress) -> bool: + return bool( + ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local + ) + + if host.isdigit(): + try: + return _ip_blocked(ipaddress.ip_address(int(host))) + except (ValueError, OverflowError): + return True + + if host.startswith("0x"): + try: + return _ip_blocked(ipaddress.ip_address(int(host, 16))) + except (ValueError, OverflowError): + return True + + try: + return _ip_blocked(ipaddress.ip_address(host)) + except ValueError: + pass + + try: + return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host))) + except OSError: + pass + + return False + class SpiderTools: """Tools for web scraping and crawling.""" @@ -59,31 +103,9 @@ def _validate_url(self, url: str) -> bool: if not parsed.hostname: return False - # Reject local/internal addresses - hostname = parsed.hostname.lower() - - # Block localhost and loopback - if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']: - return False - - # Block private IP ranges - import ipaddress - try: - ip = ipaddress.ip_address(hostname) - if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local: - return False - except ValueError: - # Not an IP address, continue with domain validation - pass - - # Block common internal domains - if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']): - return False - - # Block metadata service endpoints - if hostname in ['169.254.169.254', 'metadata.google.internal']: + if _host_is_blocked(parsed.hostname): return False - + return True except Exception:
src/praisonai-agents/tests/unit/tools/test_mentions_url_ssrf.py+10 −0 added@@ -0,0 +1,10 @@ +"""@url mentions must not fetch loopback targets.""" + +from praisonaiagents.tools.mentions import MentionsParser + + +def test_url_mention_blocks_loopback(): + parser = MentionsParser() + result = parser._process_url_mention("http://127.0.0.1:8765/") + assert result is not None + assert "Blocked" in result
src/praisonai-agents/tests/unit/tools/test_python_tools_sandbox.py+16 −0 modified@@ -112,6 +112,22 @@ def test_dunder_import_blocked(self, sandbox): result = sandbox.run("__import__('os')") assert result["success"] is False + def test_print_self_blocked(self, sandbox): + """print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh).""" + result = sandbox.run("b = print.__self__") + assert result["success"] is False + assert "restricted" in result["stderr"].lower() + + def test_vars_call_blocked(self, sandbox): + """vars() can expose builtins.__dict__ after __self__ leak.""" + result = sandbox.run("vars({})") + assert result["success"] is False + + def test_attribute_dunder_call_blocked(self, sandbox): + """Attribute calls bypass bare-name Call checks.""" + result = sandbox.run("(1).__class__.__mro__") + assert result["success"] is False + # ── Legitimate Code (all must PASS) ─────────────────────────────────────────
src/praisonai-agents/tests/unit/tools/test_spider_url_validation.py+10 −0 modified@@ -38,6 +38,16 @@ def test_still_blocks_loopback(): assert spider._validate_url("http://localhost/") is False +def test_blocks_alternate_loopback_encodings(): + """GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms.""" + spider = SpiderTools() + assert spider._validate_url("http://localhost.:8765/") is False + assert spider._validate_url("http://127.1:8765/") is False + assert spider._validate_url("http://0177.0.0.1:8765/") is False + assert spider._validate_url("http://0x7f000001:8765/") is False + assert spider._validate_url("http://2130706433:8765/") is False + + def test_rejects_non_string_input(): spider = SpiderTools() assert spider._validate_url(None) is False # type: ignore[arg-type]
src/praisonai-platform/praisonai_platform/api/deps.py+36 −0 modified@@ -71,3 +71,39 @@ async def require_workspace_member( ) user.workspace_id = workspace_id return user + + +async def require_workspace_admin( + workspace_id: str, + user: AuthIdentity = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +) -> AuthIdentity: + """Require admin or owner role in the workspace.""" + return await require_workspace_member( + workspace_id, user, session, min_role="admin" + ) + + +async def require_workspace_owner( + workspace_id: str, + user: AuthIdentity = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +) -> AuthIdentity: + """Require owner role in the workspace.""" + return await require_workspace_member( + workspace_id, user, session, min_role="owner" + ) + + +def ensure_resource_in_workspace( + resource_workspace_id: str | None, + workspace_id: str, + *, + label: str = "Resource", +) -> None: + """Reject cross-workspace access (IDOR) with a generic 404.""" + if resource_workspace_id != workspace_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{label} not found", + )
src/praisonai-platform/praisonai_platform/api/routes/agents.py+7 −1 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member from ..schemas import AgentCreate, AgentResponse, AgentUpdate from ...services.agent_service import AgentService @@ -61,6 +61,7 @@ async def get_agent( agent = await svc.get(agent_id) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") + ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") return AgentResponse.model_validate(agent) @@ -84,6 +85,7 @@ async def update_agent( ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") + ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") return AgentResponse.model_validate(agent) @@ -95,6 +97,10 @@ async def delete_agent( session: AsyncSession = Depends(get_db), ): svc = AgentService(session) + agent = await svc.get(agent_id) + if agent is None: + raise HTTPException(status_code=404, detail="Agent not found") + ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") deleted = await svc.delete(agent_id) if not deleted: raise HTTPException(status_code=404, detail="Agent not found")
src/praisonai-platform/praisonai_platform/api/routes/issues.py+17 −1 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member from ..schemas import ( CommentCreate, CommentResponse, @@ -90,6 +90,7 @@ async def get_issue( issue = await svc.get(issue_id) if issue is None: raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") return IssueResponse.model_validate(issue) @@ -114,6 +115,7 @@ async def update_issue( ) if issue is None: raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") act_svc = ActivityService(session) await act_svc.log( workspace_id, "issue.updated", "issue", issue.id, @@ -132,6 +134,10 @@ async def delete_issue( session: AsyncSession = Depends(get_db), ): svc = IssueService(session) + issue = await svc.get(issue_id) + if issue is None: + raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") deleted = await svc.delete(issue_id) if not deleted: raise HTTPException(status_code=404, detail="Issue not found") @@ -148,6 +154,11 @@ async def add_comment( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + issue_svc = IssueService(session) + issue = await issue_svc.get(issue_id) + if issue is None: + raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") svc = CommentService(session) comment = await svc.create( issue_id=issue_id, @@ -166,6 +177,11 @@ async def list_comments( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + issue_svc = IssueService(session) + issue = await issue_svc.get(issue_id) + if issue is None: + raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") svc = CommentService(session) comments = await svc.list_for_issue(issue_id) return [CommentResponse.model_validate(c) for c in comments]
src/praisonai-platform/praisonai_platform/api/routes/workspaces.py+55 −6 modified@@ -9,7 +9,13 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_current_user, get_db, require_workspace_member +from ..deps import ( + get_current_user, + get_db, + require_workspace_admin, + require_workspace_member, + require_workspace_owner, +) from ..schemas import ( MemberAdd, MemberResponse, @@ -64,7 +70,7 @@ async def get_workspace( async def update_workspace( workspace_id: str, body: WorkspaceUpdate, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): ws_svc = WorkspaceService(session) @@ -77,7 +83,7 @@ async def update_workspace( @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace( workspace_id: str, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_owner), session: AsyncSession = Depends(get_db), ): ws_svc = WorkspaceService(session) @@ -93,10 +99,16 @@ async def delete_workspace( async def add_member( workspace_id: str, body: MemberAdd, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): member_svc = MemberService(session) + if body.role == "owner": + if not await member_svc.has_role(workspace_id, user.id, "owner"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can add another owner", + ) member = await member_svc.add(workspace_id, body.user_id, body.role) return MemberResponse.model_validate(member) @@ -117,10 +129,32 @@ async def update_member_role( workspace_id: str, user_id: str, body: MemberUpdate, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): member_svc = MemberService(session) + target = await member_svc.get(workspace_id, user_id) + if target is None: + raise HTTPException(status_code=404, detail="Member not found") + if user_id == user.id and body.role != target.role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot change your own role", + ) + if body.role == "owner" and not await member_svc.has_role( + workspace_id, user.id, "owner" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can assign the owner role", + ) + if target.role == "owner" and not await member_svc.has_role( + workspace_id, user.id, "owner" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can change an owner's role", + ) member = await member_svc.update_role(workspace_id, user_id, body.role) if member is None: raise HTTPException(status_code=404, detail="Member not found") @@ -131,10 +165,25 @@ async def update_member_role( async def remove_member( workspace_id: str, user_id: str, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): member_svc = MemberService(session) + if user_id == user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot remove yourself from the workspace", + ) + target = await member_svc.get(workspace_id, user_id) + if target is None: + raise HTTPException(status_code=404, detail="Member not found") + if target.role == "owner" and not await member_svc.has_role( + workspace_id, user.id, "owner" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can remove an owner", + ) removed = await member_svc.remove(workspace_id, user_id) if not removed: raise HTTPException(status_code=404, detail="Member not found")
src/praisonai-platform/praisonai_platform/services/auth_service.py+4 −0 modified@@ -113,6 +113,10 @@ async def login(self, email: str, password: str) -> Optional[tuple[User, str]]: def _issue_token(self, user: User) -> str: """Issue a JWT for a user.""" + if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev": + raise RuntimeError( + "Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev" + ) now = datetime.now(timezone.utc) payload = { "sub": user.id,
src/praisonai-platform/tests/test_workspace_rbac.py+18 −0 added@@ -0,0 +1,18 @@ +"""Workspace member RBAC and cross-workspace IDOR guards.""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from praisonai_platform.api.deps import ensure_resource_in_workspace + + +def test_ensure_resource_in_workspace_rejects_mismatch(): + with pytest.raises(HTTPException) as exc: + ensure_resource_in_workspace("ws-a", "ws-b", label="Issue") + assert exc.value.status_code == 404 + + +def test_ensure_resource_in_workspace_allows_match(): + ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
src/praisonai/praisonai/api/agent_invoke.py+14 −2 modified@@ -29,14 +29,26 @@ # Authentication import os CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN') +_CALL_AUTH_DISABLED = os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled' + async def verify_token( request: Request, authorization: Optional[str] = Header(None) ) -> None: """Verify API token for authentication.""" - if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN: - return # No authentication if FastAPI unavailable or no token set + if not FASTAPI_AVAILABLE: + return + if _CALL_AUTH_DISABLED: + return + if not CALL_SERVER_TOKEN: + raise HTTPException( + status_code=503, + detail=( + "CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or " + "PRAISONAI_CALL_AUTH=disabled to run without authentication." + ), + ) token = None
src/praisonai/praisonai/code/tools/write_file.py+10 −11 modified@@ -58,9 +58,10 @@ def write_file( >>> if result['success']: ... print(f"Wrote to {result['path']}") """ - # Resolve path - if workspace and not os.path.isabs(path): - abs_path = os.path.abspath(os.path.join(workspace, path)) + # Resolve path — default workspace is cwd so relative paths cannot escape + effective_workspace = workspace or os.getcwd() + if not os.path.isabs(path): + abs_path = os.path.abspath(os.path.join(effective_workspace, path)) else: abs_path = os.path.abspath(path) @@ -73,14 +74,12 @@ def write_file( 'path': path, } - # Security check - ensure path is within workspace if specified - if workspace: - if not is_path_within_directory(abs_path, workspace): - return { - 'success': False, - 'error': f"Path '{path}' is outside the workspace", - 'path': path, - } + if not is_path_within_directory(abs_path, effective_workspace): + return { + 'success': False, + 'error': f"Path '{path}' is outside the workspace", + 'path': path, + } # Process content processed_content = content
src/praisonai/praisonai/mcp_server/adapters/cli_tools.py+33 −5 modified@@ -21,6 +21,31 @@ logger = logging.getLogger(__name__) +def _resolve_cwd_yaml_path(file_path: str) -> "Path": + """Resolve a YAML path strictly inside the current working directory.""" + from pathlib import Path + + if not isinstance(file_path, str) or not file_path: + raise ValueError("file_path must be a non-empty string") + if ( + "/" in file_path + or "\\" in file_path + or "\x00" in file_path + or file_path.startswith(".") + or file_path in ("..", ".") + ): + raise ValueError(f"invalid file_path: {file_path!r}") + if not file_path.endswith((".yaml", ".yml")): + raise ValueError("file_path must be a .yaml or .yml file") + base = Path.cwd().resolve() + candidate = (base / file_path).resolve() + try: + candidate.relative_to(base) + except ValueError as exc: + raise ValueError(f"invalid file_path: {file_path!r}") from exc + return candidate + + def register_cli_tools() -> None: """Register CLI-based MCP tools.""" @@ -44,7 +69,8 @@ def workflow_validate(file_path: str) -> str: """Validate a workflow YAML file.""" try: import yaml - with open(file_path, 'r') as f: + yaml_path = _resolve_cwd_yaml_path(file_path) + with open(yaml_path, 'r') as f: config = yaml.safe_load(f) required = ["framework", "topic"] @@ -64,9 +90,10 @@ def workflow_validate(file_path: str) -> str: def workflow_show(file_path: str) -> str: """Show workflow configuration.""" try: - with open(file_path, 'r') as f: - content = f.read() - return content + yaml_path = _resolve_cwd_yaml_path(file_path) + return yaml_path.read_text() + except ValueError as e: + return f"Error: {e}" except FileNotFoundError: return f"File not found: {file_path}" except Exception as e: @@ -417,7 +444,8 @@ def deploy_validate(config_path: str = "deploy.yaml") -> str: """Validate deployment configuration.""" try: import yaml - with open(config_path, 'r') as f: + yaml_path = _resolve_cwd_yaml_path(config_path) + with open(yaml_path, 'r') as f: config = yaml.safe_load(f) required = ["name", "type"]
src/praisonai/tests/unit/code/test_write_file_workspace.py+23 −0 added@@ -0,0 +1,23 @@ +"""write_file must constrain paths when workspace is omitted.""" + +from __future__ import annotations + +import os + +from praisonai.code.tools.write_file import write_file + + +def test_write_file_without_workspace_stays_in_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + ok = write_file("out.txt", "hello", workspace=None) + assert ok["success"] is True + assert (tmp_path / "out.txt").read_text() == "hello" + + +def test_write_file_without_workspace_blocks_escape(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + result = write_file("../outside.txt", "nope", workspace=None) + assert result["success"] is False + assert "outside" in result["error"].lower()
src/praisonai/tests/unit/mcp_server/test_cli_tools_path_hardening.py+21 −0 added@@ -0,0 +1,21 @@ +"""MCP CLI tools must not read arbitrary filesystem paths.""" + +from __future__ import annotations + +import pytest + + +def test_resolve_cwd_yaml_rejects_traversal(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + from praisonai.mcp_server.adapters import cli_tools + + (tmp_path / "workflow.yaml").write_text("framework: test\ntopic: t\n") + + path = cli_tools._resolve_cwd_yaml_path("workflow.yaml") + assert path.name == "workflow.yaml" + + with pytest.raises(ValueError): + cli_tools._resolve_cwd_yaml_path("../../etc/passwd") + + with pytest.raises(ValueError): + cli_tools._resolve_cwd_yaml_path("/etc/passwd")
src/praisonai/tests/unit/test_call_server_auth.py+40 −0 added@@ -0,0 +1,40 @@ +"""Call server agent API must not fail open without CALL_SERVER_TOKEN.""" + +from __future__ import annotations + +import importlib +import os + +import pytest + +pytest.importorskip("fastapi") + + +@pytest.mark.asyncio +async def test_verify_token_requires_token_by_default(monkeypatch): + monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False) + monkeypatch.delenv("PRAISONAI_CALL_AUTH", raising=False) + + mod = importlib.import_module("praisonai.api.agent_invoke") + importlib.reload(mod) + + class _Req: + query_params = {} + + with pytest.raises(Exception) as exc: + await mod.verify_token(_Req(), authorization=None) + assert "CALL_SERVER_TOKEN" in str(exc.value) + + +@pytest.mark.asyncio +async def test_verify_token_optout(monkeypatch): + monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False) + monkeypatch.setenv("PRAISONAI_CALL_AUTH", "disabled") + + mod = importlib.import_module("praisonai.api.agent_invoke") + importlib.reload(mod) + + class _Req: + query_params = {} + + await mod.verify_token(_Req(), authorization=None)
a72e156c4d01Release v4.6.40
12 files changed · +13 −13
docker/Dockerfile.chat+1 −1 modified@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.39" \ + "praisonai>=4.6.40" \ "praisonai[chat]" \ "embedchain[github,youtube]"
docker/Dockerfile.dev+1 −1 modified@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.39" \ + "praisonai>=4.6.40" \ "praisonai[ui]" \ "praisonai[chat]" \ "praisonai[realtime]" \
docker/Dockerfile.ui+1 −1 modified@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.39" \ + "praisonai>=4.6.40" \ "praisonai[ui]" \ "praisonai[crewai]"
src/praisonai-agents/pyproject.toml+1 −1 modified@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonaiagents" -version = "1.6.39" +version = "1.6.40" description = "Praison AI agents for completing complex tasks with Self Reflection Agents" readme = "README.md" requires-python = ">=3.10"
src/praisonai-agents/uv.lock+1 −1 modified@@ -2992,7 +2992,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.6.39" +version = "1.6.40" source = { editable = "." } dependencies = [ { name = "aiohttp" },
src/praisonai-platform/pyproject.toml+1 −1 modified@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonai-platform" -version = "0.1.2" +version = "0.1.4" description = "Platform layer for PraisonAI — workspace, auth, issues, projects" readme = "README.md" requires-python = ">=3.10"
src/praisonai-platform/uv.lock+1 −1 modified@@ -1208,7 +1208,7 @@ wheels = [ [[package]] name = "praisonai-platform" -version = "0.1.2" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" },
src/praisonai/praisonai/deploy.py+1 −1 modified@@ -57,7 +57,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==4.6.39 gunicorn markdown\n") + file.write("RUN pip install flask praisonai==4.6.40 gunicorn markdown\n") file.write("EXPOSE 8080\n") file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
src/praisonai/praisonai.rb+2 −2 modified@@ -3,8 +3,8 @@ 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/v4.6.39.tar.gz" - sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.39.tar.gz | shasum -a 256`.split.first + url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz" + sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz | shasum -a 256`.split.first license "MIT" depends_on "python@3.11"
src/praisonai/praisonai/version.py+1 −1 modified@@ -1 +1 @@ -__version__ = "4.6.39" \ No newline at end of file +__version__ = "4.6.40" \ No newline at end of file
src/praisonai/pyproject.toml+1 −1 modified@@ -12,7 +12,7 @@ dependencies = [ "rich>=13.7", "markdown>=3.5", "pyparsing>=3.0.0", - "praisonaiagents>=1.6.39", + "praisonaiagents>=1.6.40", "python-dotenv>=0.19.0", "litellm>=1.83.14,<2", "PyYAML>=6.0",
src/praisonai/uv.lock+1 −1 modified@@ -4841,7 +4841,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.6.39" +version = "1.6.40" source = { directory = "../praisonai-agents" } dependencies = [ { name = "aiohttp" },
Vulnerability mechanics
Root cause
"The `APIConfig` model defaults `auth_enabled` to `False`, causing the code generator to emit a Flask server whose `check_auth()` function unconditionally returns `True` and accepts all requests without authentication."
Attack vector
An attacker who can reach the generated Flask server (which binds to `0.0.0.0` per the recommended sample YAML) sends unauthenticated POST requests to the `/chat` or `/agents` endpoints. Because `check_auth()` returns `True` immediately when `AUTH_ENABLED` is `False`, no token is required. The server then invokes `praisonai.run()` on attacker-supplied JSON, giving the attacker arbitrary LLM orchestration access with the operator's API keys in the process environment [ref_id=1][ref_id=2].
Affected code
The vulnerability resides in `praisonai/deploy/models.py` (lines 29-30) where `auth_enabled` defaults to `False` and `auth_token` defaults to `None`, and in `praisonai/deploy/api.py` (lines 40-49) where the code generator emits a `check_auth()` function that short-circuits to `True` when authentication is disabled. The same insecure default is present in the shipped `src/praisonai/api_server.py` entrypoint [ref_id=1][ref_id=3].
What the fix does
The patches in `patch_id=3131097` and `patch_id=3131313` do **not** change the `auth_enabled` default from `False` to `True` in the deploy generator. Instead, they harden other components (sandbox AST checks, SSRF URL validation, MCP path containment, workspace RBAC, and IDOR scoping). The advisory explicitly states that the fix shape is opt-in: users must set `APIConfig(auth_enabled=True, auth_token=...)` to enable authentication [ref_id=1][ref_id=2]. No patch has been published that flips the default to secure.
Preconditions
- configThe operator must run `praisonai deploy --type api` with default configuration, producing a server that binds to 0.0.0.0
- networkThe generated server must be network-reachable by the attacker
- authNo authentication token is required because auth_enabled defaults to False
- inputThe attacker sends a POST request with arbitrary JSON to /chat or /agents
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.