Summary
praisonaiagents: AgentServer declares auth_token but never enforces it on any route
Advisory details
Researcher: Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research Target: https://github.com/MervinPraison/PraisonAI
Package: praisonaiagents on PyPI
Affected version (empirically tested): 1.6.48
Component: praisonaiagents.server.AgentServer (the bundled HTTP / SSE server)
TL;DR
AgentServer.ServerConfig advertises an auth_token: Optional[str] = None field that operators set when they want to lock down the server. The GET /info endpoint even displays it back as "auth_token": "***" — strongly implying the value is wired into request authentication.
It isn't. AgentServer._create_app never reads auth_token, never adds an authentication middleware, and never decorates any route with a dependency that checks it. Every route — /info, /publish, /events, /health — accepts unauthenticated requests regardless of whether auth_token is configured.
The same package contains a sibling server, praisonaiagents.ui.a2a.A2A, written by the same developer, which implements the bearer-token pattern correctly via FastAPI's Depends(_verify_auth). This rules out the "auth is not yet implemented; operators are expected to add it" reading: the developer knew the pattern but did not apply it to AgentServer.
Root cause
Expected behavior when setting ServerConfig(auth_token="…"):
"Only requests with a matching Authorization header will be
accepted on /publish, /events, /info."
Actual behavior (server/server.py, dist 1.6.48):
- line 31 auth_token: Optional[str] = None # declared
- line 39 "auth_token": "***" if self.auth_token else None # displayed
- lines 122-204: no auth middleware, no Depends, no
request.headers["Authorization"] read,
no comparison to self.config.auth_token.
Impact:
The configuration knob is dead code from the route handlers'
perspective. All routes always run. The operator has no signal
that their auth_token was discarded — /info even confirms it
was received by displaying "***".
Sibling proof — the same package gets it right elsewhere
praisonaiagents/ui/a2a/a2a.py:
# line 163
async def _verify_auth(authorization: Optional[str] = Header(None)):
"""Verify bearer token if auth_token is configured."""
if self.auth_token is None:
return
...
if len(parts) != 2 or parts[0].lower() != "bearer" \
or parts[1] != self.auth_token:
raise HTTPException(status_code=401, ...)
# line 192
from fastapi import Depends
_a2a_deps = [Depends(_verify_auth)] if self.auth_token else []
That is the missing implementation. Porting it to AgentServer — either via Starlette BaseHTTPMiddleware or by switching to FastAPI and adding Depends(_verify_auth) to each route — closes the gap.
Affected routes (empirically tested)
| Route | Method | Accepts unauth requests? | Impact |
|---|---|---|---|
/info |
GET | Yes (200) | Leaks server config; confirms auth_token is set ("***"); reveals client count and CORS config. |
/publish |
POST | Yes (200) | Anyone broadcasts arbitrary {type, data} to every subscribed agent. Event payload is whatever the attacker sends. |
/events |
GET | Yes (200) | Anyone subscribes to the SSE stream and observes every event published by the server (and by any other anonymous attacker). |
/health |
GET | Yes (200) | Leaks live SSE client count. |
Impact
The /publish and /events routes are the load-bearing ones. Together they let an unauthenticated network-adjacent attacker:
- Inject control events into every agent process subscribed to the server.
AgentServer.broadcast(event_type, data)puts the payload into everySSEClient.queue; any consumer dispatching onevent_typewill dispatch on the attacker-chosen type. Real deployments register handlers per event type viaAgentServer.on_event(...); an attacker who can guess (or enumerate via/info+ inspection) a registered type can drive arbitrary handler invocations with attacker-chosendata. - Eavesdrop on the entire event bus by subscribing to
/events. Whatever the legitimate publishers send is visible: agent observations, intermediate plans, tool inputs and outputs, user-supplied prompts that the operator believed were behind theauth_tokenwall. - Pivot via leaked config.
/infois sufficient to enumeratecors_origins(helping plan cross-origin attacks if any of the listed origins are attacker-controlled) and to confirm that the target has bothered to setauth_token, signalling a high-value target.
SSEClient.queue is a queue.Queue with no documented size cap; the event broadcaster does not check max_connections against publishers, only subscribers. An attacker can also flood /publish to fill every subscriber's queue, denying service to legitimate broadcasts (CWE-770). Not scored as the main impact above.
Anchors
praisonaiagents 1.6.48, file praisonaiagents/server/server.py:
| Line | Symbol | What it shows |
|---|---|---|
| 31 | auth_token: Optional[str] = None |
Declared. |
| 39 | "auth_token": "***" if self.auth_token else None |
Displayed (masked) in /info. |
| 121 | def _create_app(self): |
Route + middleware setup begins. |
| 132 | async def health(request): |
No auth check. |
| 139 | async def events(request): |
No auth check. |
| 164 | async def publish(request): |
No auth check. |
| 182 | async def info(request): |
No auth check. |
| 190 | routes = [Route("/health", …), Route("/events", …), Route("/publish", …), Route("/info", …)] |
Routes registered without Depends/middleware. |
| 197 | app = Starlette(routes=routes) |
App created. |
| 200 | app = CORSMiddleware(app, …) |
Only middleware added. |
Source sha256 (1.6.48, praisonaiagents/server/server.py): aac9497d515b5cb928070267b860b11ef38b537605e64659feef895b524ca7e4 (9,962 bytes).
Sibling (same package, same field name, enforced): praisonaiagents/ui/a2a/a2a.py:163-193.
Reproduction (empirical PoC)
poc/poc.py starts AgentServer with ServerConfig(auth_token="supersecret-not-actually-checked") and then sends unauthenticated requests to each route.
Run log (poc/run-log.txt):
[1] GET /info (no Authorization) -> HTTP 200
body: {"name":"PraisonAI Agent Server","version":"1.0.0","clients":0,
"config":{"host":"127.0.0.1","port":18765,"cors_origins":[],
"auth_token":"***","max_connections":100}}
[2] POST /publish (no Authorization) -> HTTP 200
body: {"success":true,"clients":0}
[3] GET /health (no Authorization) -> HTTP 200
[4] GET /events (no Authorization) -> HTTP 200
VULNERABLE: 4 unauthenticated routes
VERDICT: VULNERABLE
EXIT 0
Suggested fix
Make AgentServer reuse the A2A pattern. Smallest fix:
# in _create_app, after `app = Starlette(routes=routes)`:
if self.config.auth_token:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
expected = "Bearer " + self.config.auth_token
class _Auth(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path == "/health": # if /health should remain publi
References
Related vulnerabilities
All Supply chain →- CRITICALCVE-2026-73842
OpenChoreo: cluster-gateway internal proxy performs no caller authentication and is not read-only — data-plane Secret disclosure and arbitrary Kubernetes mutation
- CRITICALCVE-2026-73843
OpenChoreo: Unauthenticated access to data-plane operations via OpenChoreo cluster-gateway management APIs
- HIGHGHSA-7q9c-hpx7-9cwm
TypeSpec: Unauthenticated Remote Shutdown of Spector Mock Server via POST /.admin/stop
- HIGHCVE-2026-72795
SiYuan: Embedded (transclusion) block content is returned without publish-access filtering, leaking private and password-protected document content to anonymous readers
- MEDIUMCVE-2026-72796
SiYuan: Static-file routes bypass the publish-access controls enforced on the REST API, exposing templates, snippets and export artifacts to anonymous readers
- MEDIUMCVE-2026-72797
SiYuan: getEncryptedNotebookStatus discloses names and current lock/unlock state of all encrypted notebooks to anonymous readers