Root cause
Authentication & access control introduced
fix: harden agent invoke execution path and logger init order
How AI contributed: Direct introductionPublished May 29, 2026 in
mervinpraison/praisonaiAll 19 findings in this repository
PraisonAI call server unauthenticated agent management: verify_token() in praisonai/api/agent_invoke.py failed open when CALL_SERVER_TOKEN was unset (if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN: return), allowing unauthenticated network callers to list agents, read agent instructions and metadata, invoke agents, and unregister agents on the public 0.0.0.0 call server.
Root cause
fix: harden agent invoke execution path and logger init order
Fix
refactor: harden input validation and access controls
Case facts
@@ -10,7 +10,7 @@ import logging try:- from fastapi import APIRouter, HTTPException, Depends+ from fastapi import APIRouter, HTTPException, Depends, Header, Request from pydantic import BaseModel, Field FASTAPI_AVAILABLE = True except ImportError:@@ -19,10 +19,48 @@ HTTPException = None BaseModel = object Field = lambda *args, **kwargs: None+ Depends = lambda x: x+ Header = lambda *args, **kwargs: None+ Request = object FASTAPI_AVAILABLE = False logger = logging.getLogger(__name__) +# Authentication+import os+CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')++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+ + token = None+ + # Check Authorization header first (Bearer or Basic)+ if authorization:+ if authorization.startswith("Bearer "):+ token = authorization.split(" ")[1]+ elif authorization.startswith("Basic "):+ try:+ import base64+ decoded = base64.b64decode(authorization[6:]).decode("utf-8")+ if ":" in decoded:+ token = decoded.split(":", 1)[1] # Use password as token+ else:+ token = decoded+ except Exception:+ pass+ + # Check query param as fallback+ if not token:+ token = request.query_params.get("token")+ + if token != CALL_SERVER_TOKEN:+ raise HTTPException(status_code=401, detail="Unauthorized") # Request/Response Models if FASTAPI_AVAILABLE:@@ -114,7 +152,8 @@ def _supports_sync_start(agent: Any) -> bool: @router.post("/agents/{agent_id}/invoke") async def invoke_agent( agent_id: str,- request: AgentInvokeRequest+ request: AgentInvokeRequest,+ _: None = Depends(verify_token) ) -> Union[AgentInvokeResponse, ErrorResponse]: """ Invoke a PraisonAI agent with a message.@@ -150,7 +189,7 @@ async def invoke_agent( logger.error(f"Agent not found: {agent_id}") raise HTTPException( status_code=404,- detail=f"Agent '{agent_id}' not found. Available agents: {list_registered_agents()}"+ detail=f"Agent '{agent_id}' not found" ) try:@@ -168,8 +207,10 @@ async def invoke_agent( # Async agent result = await agent.astart(request.message) elif _supports_sync_start(agent):- # Sync agent (use start method)- result = agent.start(request.message)+ # Sync agent - run in thread pool to avoid blocking the event loop+ import asyncio+ loop = asyncio.get_event_loop()+ result = await loop.run_in_executor(None, agent.start, request.message) else: raise AttributeError(f"Agent {agent_id} must provide start() or async astart()") @@ -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"]@@ -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@@ -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 AI-assisted change 828e72695eb25522bdf54edf28410feb6dc3595c3a92638f37a65b9f66264bfc · Fix 522bbb6dbffd666e07aecf48c18bd61819c24be8d7c56b9c2603458ea9aa8569