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 | for allowed in self.allowed_origins: |
"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:
- Servers MUST validate the
Originheader on all incoming connections to prevent DNS rebinding attacks- When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)
- 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 | if allowed_origins is None: |
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 | for allowed in self.allowed_origins: |
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:
- The attacker owns
evil.example. - They create the DNS record
localhost.evil.examplepointing at their own web server. There is no restriction on usinglocalhostas a subdomain label. - The victim loads
http://localhost.evil.example/. - Script on that page issues a request to
http://127.0.0.1:8080/mcp. - The browser attaches
Origin: http://localhost.evil.example, exactly as the specification requires for a cross-origin request. - The MCP transport prefix-matches it against
http://localhostand returnsTrue.
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 | bypass = client.post( |
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 | if self.api_key: |
And the CLI defaults leave it unset:
1 | parser.add_argument("--host", default="127.0.0.1") |
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 | def _register_all(): |
The server then dispatches tools/call by name with caller-supplied arguments:
1 | tool_name = params.get("name") |
Two registered tools illustrate the reachable capability. praisonai.files.create accepts a local path:
1 |
|
…and file_create() opens that string as a local file and passes the handle onward:
1 | file_obj = file |
The second, praisonai.todo.add, writes attacker-supplied content into ~/.praison/todo.json.
1 | Attacker page at http://localhost.evil.example |
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/callrequests 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-keyis set, the request is rejected with401regardless 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;
8080is 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
- Inventory PraisonAI installations and flag anything before 4.6.58.
- Find every place an MCP server is launched — shell aliases,
Makefiletargets, editor and IDE MCP client configs,launchd/systemdunits, and developer runbooks. - For each, record the transport, the
--host, the--api-key, and the--allowed-originsvalues. - Treat any HTTP Stream server started without
--api-keyas unauthenticated, including on loopback. - Confirm whether
--allowed-originswas 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 | # On your own machine only. Compares origin handling; does not call any tool. |
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.jsonthat no one on the machine created; - MCP access logs showing
Originvalues that are not exactly a local development origin you recognize — specifically any host with additional labels afterlocalhostor127.0.0.1; tools/callrequests carryingContent-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-originsexplicitly 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 | # Defensive pseudocode. |
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.
Related reading
- CVE-2026-55533: PraisonAI Recipe Server Authentication Fail-Open — the other issue fixed in PraisonAI 4.6.58, where selecting
api-keyorjwtwithout a secret silently disables authentication. - CVE-2026-57572: Crawl4AI Unauthenticated RCE via Chromium Argument Injection — another AI-infrastructure service where a permissive configuration path crossed a trust boundary.
References
- CVE.org record: CVE-2026-55529
- NIST National Vulnerability Database: CVE-2026-55529
- GitHub Security Advisory: GHSA-wj6g-v78p-6fx3
- PraisonAI v4.6.58 release
- Fix commit 2f9677a
- CWE-346: Origin Validation Error
- CWE-306: Missing Authentication for Critical Function
- Model Context Protocol specification: Transports, Streamable HTTP security warning
Public vulnerability records change as maintainers and databases add references or revise metadata. Recheck the primary sources above during remediation and incident response.