GitHub Advisoryadvisory · PyPI
GHSA-cwj8-7gp2-ggcw praisonai-platform: default JWT signing secret 'dev-secret-change-me' enables token forgery
CRITICAL
Official page ↗
- Aliases
- CVE-2026-57147
- Ecosystem
- PyPI
- Packages
- praisonai-platform
- Severity
- Critical
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
- Weaknesses
- CWE-1188, CWE-798
- Reviewed
- 2026-06-18
Affected packages
• PyPI praisonai-platform: < 0.1.6 → fixed in 0.1.6
Description
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= [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.
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`):
| 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.
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 rathe…
Related
Alias · CVE (1)
- CVE-2026-57147 PraisonAI is a multi-agent teams system. vulnerability