Leantime: Any Authenticated User Can Self-Escalate to Admin via Unauthorized JSON-RPC Methods
CVE-2026-85990 · GHSA-54mm-6729-r5wp · published 15 Oct 2026
How serious is it
- Severity: high, CVSS score 8.8 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-85990?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
leantime/leantime(Packagist) < 3.10.0
How to check
- Run
composer show leantime/leantimein your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
leantime/leantimeto 3.10.0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
### Summary
Any 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.
### Details
Leantime'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.
`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:
```php
// app/Domain/Api/Services/Api.php
/**
* @api
*/
public function createApiKeyWithProjects(array $values, ?array $projects): array|false
{
$apiKeyValues = $this->createAPIKey($values); // no #[RequiresPermission], no role check
...
}
```
The 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.
Separately, `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`.
Authenticating 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.
### PoC
A runnable Python PoC (`requests` library only) reproduces this end-to-end; see the attached `poc.py`. Steps:
1. 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`.
2. From that same low-privilege session, send:
```
POST /api/jsonrpc
Content-Type: application/json
X-Requested-With: XMLHttpRequest
{"jsonrpc":"2.0","id":1,"method":"leantime.rpc.api.api.createApiKeyWithProjects",
"params":{"values":{"firstname":"poc","role":40,"status":"a"},"projects":[]}}
```
The server responds `HTTP 200` with the newly created account's `user`/`passwordClean` and `"role":40` (admin) in the JSON body.
3. 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.
Reproduced 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.
### Impact
Complete 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.
Script : poc.py
```python
#!/usr/bin/env python3
"""
What this script does, against a target Leantime instance:
1. Logs in as a LOW-PRIVILEGE account (readonly role by default).
2. Confirms the account is correctly blocked by the normal web UI from an
admin-only page (expects HTTP 403) -- this establishes the account truly
has no admin rights before the exploit runs.
3. Calls leantime.rpc.api.api.createApiKeyWithProjects over JSON-RPC using
nothing but that account's own ordinary session cookie, requesting
role=40 (admin). No admin/API-key credential is used anywhere in this
step -- only the low-privilege session established in step 1.
4. Logs in through the completely ordinary web login form using the
generated username/password from step 3, and confirms that account now
has full, interactive admin access to the same admin-only page that
blocked the original low-privilege account in step 2. (The record is
created with source='' rather than source='api' -- createAPIKey() never
sets that column -- so it does NOT work as an x-api-key credential, but
it is a fully functional role=40 web login, which is a stronger and
more direct proof of impact.)
Requ…
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/Leantime/leantime/security/advisories/GHSA-54mm-6729-r5wp
- nvd.nist.gov/vuln/detail/CVE-2026-85990
- github.com/Leantime/leantime/pull/3583
- github.com/Leantime/leantime/pull/3797
- github.com/Leantime/leantime/commit/5605e5ee86bbd01acb85c6b5d8fb337faf8bcf02
- github.com/Leantime/leantime/commit/7ebf7a9517e2aaa05ec15b8130ab1841a4bde43e
- github.com/Leantime/leantime/releases/tag/v3.10.0
- github.com/advisories/GHSA-54mm-6729-r5wp
Merged from GitHub Advisory Database. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON