Scope and safety. This article analyzes a public vulnerability record I reported, together with its defensive implications. The reproduction referenced below is the local, in-process proof of concept published in the advisory — it stubs out the LLM provider entirely so no data leaves the machine, and it contains no hosted attack page, no exfiltration infrastructure, and no instructions for targeting systems you do not own.

Executive summary

CVE-2026-55529 is an origin validation bypass in PraisonAI’s MCP HTTP Stream transport, affecting versions before 4.6.58. When the transport binds to localhost and no explicit origin allowlist is configured, it installs a default allowlist containing bare origins such as http://localhost and http://127.0.0.1. The validator then accepts any origin that starts with an allowlisted value:

1
2
3
for allowed in self.allowed_origins:
if request_origin == allowed or request_origin.startswith(allowed):
return True

"http://localhost.evil.example".startswith("http://localhost") is True. An attacker who controls any domain can create that subdomain, host a page on it, and every request the page makes to the victim’s local MCP server carries a trusted-looking Origin.

This matters because the MCP CLI defaults to no API key. Authentication is only enforced if self.api_key:, so a server started with praisonai mcp --transport http-stream is, by default, an unauthenticated JSON-RPC endpoint that dispatches tools/call to every tool registered by register_all(). In local testing, the bypassed request invoked praisonai.files.create — which opens an attacker-chosen local path and hands the file object to the configured LiteLLM provider — and praisonai.todo.add, which writes attacker-controlled content into ~/.praison/todo.json.

The issue is classified as CWE-346: Origin Validation Error and CWE-306: Missing Authentication for Critical Function, rated Moderate with the CVSS 3.1 vector CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N — a base score of 6.9 by the CVSS 3.1 formula. The default bind address is 127.0.0.1, so this is a browser-mediated local attack, not a directly internet-facing API. PraisonAI fixed it in 4.6.58.

Evidence labels used below:

  • Verified means stated by the linked CVE, GitHub advisory, or project release material.
  • Inferred means a deployment or architectural consequence derived from those facts and clearly identified as such.

Key facts

Item Finding Confidence
Identifier CVE-2026-55529 Verified
Advisory GHSA-wj6g-v78p-6fx3 Verified
Reporter hoanggxyuuki Verified (advisory credits)
Analyst NguyenHuyTrung Verified (advisory credits)
Package PraisonAI (pip) Verified
Affected versions Versions before 4.6.58 Verified
Fixed version 4.6.58 Verified
Weakness Origin validation error (CWE-346), missing authentication (CWE-306) Verified
Component src/praisonai/praisonai/mcp_server/transports/http_stream.py Verified
Root cause request_origin.startswith(allowed) against a bare-origin allowlist Verified
Bypass origin http://localhost.evil.example Verified
Default posture Host 127.0.0.1, API key None, allowed origins None Verified
Demonstrated tools praisonai.files.create, praisonai.todo.add Verified
Preconditions Victim runs an HTTP Stream MCP server without an API key and loads an attacker-controlled page Verified
Severity Moderate, AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N Verified (vector); score 6.9 derived from the vector
EPSS 2nd percentile at time of publication Verified
Fix commit MervinPraison/PraisonAI@2f9677a Verified

Background: why a local MCP server needs Origin checks at all

A developer running an MCP server on 127.0.0.1:8080 reasonably assumes it is private. No firewall rule exposes it; no router forwards to it; netstat confirms it is bound to loopback.

The assumption is wrong in exactly one direction: the victim’s own browser is inside the loopback boundary. Any webpage the victim loads can issue requests to http://127.0.0.1:8080/ — the browser is happy to make the connection, and the loopback interface is happy to accept it. This is the classic confused-deputy shape behind DNS rebinding and every “local service reachable from the web” finding of the last fifteen years.

The Model Context Protocol specification addresses this directly. Its Streamable HTTP security warning states:

  1. Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks
  2. When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)
  3. Servers SHOULD implement proper authentication for all connections

Without these protections, attackers could use DNS rebinding to interact with local MCP servers from remote websites.

PraisonAI followed the first two points and made the third optional. The origin check exists, the default allowlist exists, the bind is on loopback, and a plainly malicious origin like https://evil.example is rejected with 403. The control was designed correctly and implemented with one bad comparison — which, with authentication off by default, was the whole boundary.

Root-cause analysis

The default allowlist uses bare origins

When allowed_origins is not explicitly configured and the server binds to a loopback address, the transport populates a default allowlist:

1
2
3
4
5
6
7
8
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",
]

Note what these entries do not contain: a port. A real browser page served from a local dev server sends Origin: http://localhost:3000, not Origin: http://localhost. An exact-match comparison against this list would reject every legitimate local development origin.

That is the trap. The bare-origin list creates pressure for a loose comparison, and the loose comparison is what follows.

The comparison is a string prefix, not an origin comparison

1
2
3
for allowed in self.allowed_origins:
if request_origin == allowed or request_origin.startswith(allowed):
return True

startswith solves the port problem — http://localhost:3000 does begin with http://localhost — and in doing so accepts a much larger set than intended. The string http://localhost is a prefix of all of the following, none of which are localhost:

Origin Passes? Why
http://localhost:3000 Yes Intended case: a local dev server
http://localhost.evil.example Yes Bypass. A subdomain the attacker fully controls
http://localhost-console.evil.example Yes Hyphen continues the label; still an attacker domain
http://127.0.0.1.evil.example Yes Same shape against the IP-literal entry
https://evil.example No Rejected with 403 — the control works against the obvious case

The boundary between a hostname and the next label is ., and between a host and a port is :. A prefix match respects neither. It treats the origin as an opaque string when the origin is a structured value with a grammar.

Registering localhost.evil.example is trivial and needs no rebinding

The advisory frames this as “DNS-rebinding-style,” and the class is the same. In practice the prefix bypass is simpler than rebinding, because the attacker does not need to flip a DNS answer at all:

  1. The attacker owns evil.example.
  2. They create the DNS record localhost.evil.example pointing at their own web server. There is no restriction on using localhost as a subdomain label.
  3. The victim loads http://localhost.evil.example/.
  4. Script on that page issues a request to http://127.0.0.1:8080/mcp.
  5. The browser attaches Origin: http://localhost.evil.example, exactly as the specification requires for a cross-origin request.
  6. The MCP transport prefix-matches it against http://localhost and returns True.

No rebinding race, no TTL manipulation, no timing. The attacker simply names a host in a way that defeats the comparison.

The request crosses the browser’s own defenses as a “simple request”

A cross-origin POST normally triggers a CORS preflight, which would give the server a second chance to refuse. The published proof of concept posts with Content-Type: text/plain:

1
2
3
4
5
6
7
8
bypass = client.post(
"/mcp",
data=json.dumps(file_payload),
headers={
"Origin": "http://localhost.evil.example",
"Content-Type": "text/plain",
},
)

text/plain is one of the three content types that qualify a cross-origin POST as a CORS simple request, meaning the browser sends it directly with no preflight. The body is still valid JSON-RPC, and the server parses it as such. So the one browser-level check that might have intervened never runs.

Inferred: this is why the finding is about side effects rather than only about reading a response. Whether the attacker page can read the JSON-RPC reply depends on the response’s CORS headers and the server’s cors_origins configuration. Whether the tool executes does not — by the time any response header matters, the file has already been opened and the todo entry has already been written.

Authentication is opt-in, and the CLI opts out

The transport enforces credentials only when one is configured:

1
2
3
4
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)

And the CLI defaults leave it unset:

1
2
3
4
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")

So in the default configuration the Origin check is not one layer of defense among several — it is the only thing standing between a webpage and the tool dispatcher. A single flawed comparison is therefore a complete bypass.

What sits behind the bypassed check

Before serving, the CLI calls register_all(), which wires up the full tool surface:

1
2
3
4
5
6
def _register_all():
register_all_tools()
register_extended_capability_tools()
register_cli_tools()
register_mcp_resources()
register_mcp_prompts()

The server then dispatches tools/call by name with caller-supplied arguments:

1
2
3
4
5
6
7
8
9
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)

Two registered tools illustrate the reachable capability. praisonai.files.create accepts a local path:

1
2
3
4
@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)

…and file_create() opens that string as a local file and passes the handle onward:

1
2
3
4
5
file_obj = file
if isinstance(file, str):
file_obj = open(file, 'rb')

response = litellm.create_file(**call_kwargs)

The second, praisonai.todo.add, writes attacker-supplied content into ~/.praison/todo.json.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Attacker page at http://localhost.evil.example
|
| fetch("http://127.0.0.1:8080/mcp", {method:"POST",
| headers:{"Content-Type":"text/plain"}, body: JSON-RPC})
v
Browser -- no preflight (simple request), Origin attached --> loopback
|
v
HTTPStreamTransport._validate_origin()
| startswith("http://localhost") -> True
v
MCPServer.handle_message() -> tools/call
|
+--> praisonai.files.create -> open(attacker_path) -> LiteLLM provider
+--> praisonai.todo.add -> write ~/.praison/todo.json

Reproduction summary

The published proof of concept runs entirely in-process. It injects a fake litellm module so that “uploading” a file only captures its contents in a local dictionary, sets HOME to a temporary directory, and writes a harmless marker file to read back. Nothing is sent to any provider and nothing outside the temp directory is touched.

The measured results on an affected version:

Request Origin Result
tools/call → praisonai.files.create https://evil.example 403 — correctly blocked
tools/call → praisonai.files.create http://localhost.evil.example 200 — bypass; the marker file’s contents are read and handed to the provider stub
tools/call → praisonai.todo.add http://localhost.evil.example 200 — ~/.praison/todo.json created with attacker-supplied content

The first row is the control: the origin check genuinely rejects an unrelated attacker origin. The difference between rows one and two is nothing but the shape of the hostname.

Impact and its honest limits

Verified impact

  • A malicious webpage can bypass the localhost origin allowlist and issue tools/call requests to a locally running, unauthenticated HTTP Stream MCP server.
  • In local testing this invoked a tool that reads an attacker-selected local file path and passes the file handle to the configured LiteLLM provider.
  • It also invoked a tool that modifies local PraisonAI state by writing to ~/.praison/todo.json.
  • The CVSS vector records High confidentiality and Low integrity impact with Changed scope, reflecting that the compromise crosses from the browser into the victim’s local machine and its files.

The file-read path is the reason confidentiality is rated High. The tool does not return file contents to the caller directly — it sends them to the configured LLM provider. Inferred: that is still a disclosure channel, since the operator’s provider account receives the file, and any path the process can read is a candidate: SSH keys, cloud credential files, .env files, shell history, source trees.

Preconditions that limit it

I would rather state the constraints plainly than overstate the finding:

  • The default bind is 127.0.0.1, so this is not a directly internet-reachable API in the default configuration.
  • The victim must be running the HTTP Stream transport at the time.
  • The victim must be running it without an API key. If --api-key is set, the request is rejected with 401 regardless of origin, and exploitability drops sharply.
  • The victim must load an attacker-controlled page whose hostname matches the prefix, or be reached through a rebinding-style setup.
  • The attacker needs to know or guess the port; 8080 is the default, which is not much of an obstacle, but it is a requirement.

AC:H and UI:R in the vector encode exactly these conditions. This is a Moderate finding and I have no interest in dressing it up as more.

Deployment-dependent escalation

The following are inferred and depend on what the victim’s installation has registered and configured:

  • the full registered tool surface is reachable, not only the two tools demonstrated — the practical ceiling is set by register_all() in that version and by the environment the process runs in;
  • provider API keys present in the process environment are spent on attacker-triggered calls;
  • writes into ~/.praison/ state are a second-order injection channel: content an agent will later read as its own task list is content an attacker chose, which is textbook indirect prompt injection against whatever runs next;
  • a developer workstation is a high-value context — source code, credentials, and internal network reachability all sit within the same process’s reach.

Safe validation and detection

Configuration review

  1. Inventory PraisonAI installations and flag anything before 4.6.58.
  2. Find every place an MCP server is launched — shell aliases, Makefile targets, editor and IDE MCP client configs, launchd/systemd units, and developer runbooks.
  3. For each, record the transport, the --host, the --api-key, and the --allowed-origins values.
  4. Treat any HTTP Stream server started without --api-key as unauthenticated, including on loopback.
  5. Confirm whether --allowed-origins was ever set explicitly. If it was not, the vulnerable default allowlist applied.

Non-destructive runtime check

On a machine you own, the presence of the flaw can be confirmed without invoking any tool. Send a request the server will reject for an unrelated reason and compare the origin handling:

1
2
3
4
5
6
7
8
# On your own machine only. Compares origin handling; does not call any tool.
for origin in "https://evil.example" "http://localhost.evil.example"; do
printf '%s -> ' "$origin"
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST "http://127.0.0.1:8080/mcp" \
-H "Origin: $origin" -H "Content-Type: text/plain" \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
done

On an affected server the first origin returns 403 and the second does not. tools/list enumerates rather than executes, so nothing is invoked. On a fixed or properly key-protected server, both are refused.

Host and log review

Look for:

  • entries in ~/.praison/todo.json that no one on the machine created;
  • MCP access logs showing Origin values that are not exactly a local development origin you recognize — specifically any host with additional labels after localhost or 127.0.0.1;
  • tools/call requests carrying Content-Type: text/plain, which is unusual for a legitimate MCP client and expected for a browser simple-request bypass;
  • provider usage — file uploads in particular — that does not correspond to your own activity;
  • reads of sensitive paths by the MCP server process, if you have endpoint telemetry on the workstation.

Inferred: browser-mediated attacks leave little evidence on the server side, so the origin field is the highest-value log line here. If your MCP server does not record Origin on each request, add it.

Remediation and hardening

Required remediation

Upgrade to PraisonAI 4.6.58 or later. The fix is in commit 2f9677a, shipped in the v4.6.58 release, which also addresses the Recipe server fail-open issue tracked as CVE-2026-55533.

Defense in depth

  • Always set --api-key, even on loopback. This is the single highest-value change. With a key configured, the origin bypass alone does not produce tool execution.
  • Set --allowed-origins explicitly to the exact origins your clients use, including their ports, rather than relying on any default.
  • Prefer stdio transport for local MCP clients that do not need HTTP. A transport a browser cannot reach is not exposed to this class at all.
  • Keep the bind address on loopback, and never expose an MCP HTTP endpoint on 0.0.0.0.
  • Bind to a non-default port to raise the cost of blind targeting. This is friction, not a control.
  • Run the MCP server as a user with the least filesystem reach the workflow allows, and avoid launching it from a directory or shell session that carries credentials it does not need.
  • Review the registered tool surface. If your workflow does not need file-upload or CLI tooling, do not register it — every registered tool is ambient authority available to anything that reaches the dispatcher.
  • If you cannot rule out exposure, rotate provider keys and any credentials readable by the server process, and inspect ~/.praison/ state for content you did not author.

Timeline

Date Event Source certainty
2026-06-13 GitHub shows the advisory published in MervinPraison/PraisonAI, alongside the v4.6.58 release containing fix commit 2f9677a. Primary advisory and release page
Around 2026-08-25 GitHub records publication to the GitHub Advisory Database, with review completed and metadata last updated around the same time. Advisory page, displayed as a relative timestamp at time of writing
2026-08-29 This analysis published. This site

Advisory records are reserved, published, reviewed, and enriched at different times. Read the live records for authoritative chronology; nothing here should be treated as the vendor-notification or discovery date.

Lessons for builders

startswith is not an origin comparison

An origin is a structured triple of scheme, host, and port. Comparing it as an opaque string discards the delimiters that define where the host ends, which is precisely the information the check depends on. Parse it:

1
2
3
4
5
6
7
8
9
10
# Defensive pseudocode.
from urllib.parse import urlsplit

LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
LOCAL_SCHEMES = {"http", "https"}

def is_local_origin(request_origin: str) -> bool:
parts = urlsplit(request_origin)
# hostname is normalized and strips any port or brackets.
return parts.scheme in LOCAL_SCHEMES and parts.hostname in LOCAL_HOSTS

For an explicitly configured allowlist, normalize both sides and compare for equality — never by prefix, and never by in. If arbitrary ports must be permitted, permit them explicitly through the parsed port field rather than by loosening the string comparison until ports happen to fit.

The loose comparison usually has a legitimate motive

It is worth being precise about how this bug arises, because “don’t use startswith“ is not a useful lesson on its own. The allowlist contained bare origins with no port; exact matching would have broken every real local client; startswith made it work. Each step is locally reasonable.

The general pattern: when a security comparison is loosened to accommodate a legitimate variation, the fix is to model that variation explicitly, not to weaken the comparison. The variation here was “any port on a local host.” Modeling it costs three lines and closes the class.

Localhost is not a security boundary

Anything bound to 127.0.0.1 is reachable by every page the user visits. Loopback binding limits who can route to you; it does not limit who can cause a request to be sent to you. A local service that performs privileged actions needs authentication for the same reason a public one does — and on a developer workstation, “privileged” includes reading files and spending API credits.

Do not let a single check carry the whole boundary

If the CLI had defaulted to generating an API key, this would be a defense-in-depth weakness rather than a full bypass. Security-relevant defaults should be conservative even when they add friction: a printed key the user must copy is a small cost against an unauthenticated tool dispatcher on a developer’s machine.

Tool registration is ambient authority

register_all() is convenient and it means every registered tool is reachable through any gap in any transport. Registered capability is not neutral — it is the blast radius of every authentication and validation bug in the layers above it. Register the tools a deployment actually needs, and treat the registry itself as a security-relevant configuration surface.

Writes into agent state are an injection channel

praisonai.todo.add looks like the least interesting part of this finding: it writes a small JSON file. But agent state is read back by an agent and interpreted as instructions or tasks. Anywhere untrusted input can reach a store that a model later consumes, the integrity impact of a small write is larger than its size suggests. When threat-modeling an agent framework, map which files the model reads as context and treat writes to them as privileged.

References

Public vulnerability records change as maintainers and databases add references or revise metadata. Recheck the primary sources above during remediation and incident response.