Two unguarded onboarding methods in Leantime’s JSON-RPC API let a low-privilege authenticated user overwrite another user’s password and role, activate the modified account, and take control of the instance owner.
Most broken access-control bugs involve a check that exists but is wrong: a flipped comparison, the wrong project ID, an allowlist with one too many entries. This bug is different. The check is absent. Leantime authorizes RPC calls with a method attribute, and when the attribute is missing, the enforcer does nothing. The onboarding methods have neither the attribute nor any other authorization check, so they run with whatever parameters the caller sends.
Leantime is an open-source project-management application, self-hosted by companies that expect their project data to stay private. It ships a JSON-RPC endpoint at /api/jsonrpc that exposes internal service methods to the network. The dispatch mechanism is generic: leantime.rpc.{module}.{service}.{method} maps to Domain\{Module}\Services\{Service}::{method}, and reflection decides at runtime what is callable.
The password and role overwrite was reproduced locally against the official v3.9.8 source tree built in Docker. The affected authorization and onboarding paths were also confirmed by source review in the v3.9.8 tag (32be54ed) and current master (056835f1, fetched September 12, 2026). No Leantime-operated infrastructure or third-party host was tested.
Leantime previously disclosed CVE-2026-85990, which described the same missing per-method authorization mechanism in createApiKeyWithProjects and several plugin-management methods and listed versions through 3.9.5 as affected. The onboarding path documented here is an additional route that remains present in v3.9.8 and the checked master revision. This post documents these additional affected methods and their owner-account-takeover impact, rather than presenting the underlying dispatcher behavior as newly identified.
What stops any method
Central authorization for the RPC surface lives in app/Core/Auth/Permissions/PermissionEnforcer.php. Once per request it is asked whether the resolved method is allowed. Its decision starts here:
public function enforce(object|string $class, string $method, array $params = []): void
{
$attribute = $this->attributeFor($class, $method);
if ($attribute === null) {
return;
}
// ... a global or project-scored permission check,
// then either return or throw AuthorizationException.
}
attributeFor reflects on the method and returns the first #[RequiresPermission] attribute it finds:
private function attributeFor(object|string $class, string $method): ?RequiresPermission
{
// ...
$attributes = (new ReflectionMethod($className, $method))
->getAttributes(RequiresPermission::class);
if ($attributes !== []) {
$attribute = $attributes[0]->newInstance();
}
// ...
return $attribute; // null when none declared
}
If there is no attribute, attributeFor returns null, enforce returns void, and the dispatcher moves on. The method runs unless it performs its own authorization check. The affected onboarding methods do not.
permissions.enforce defaults to true, so this is not an audit mode that somebody forgot to switch on. Enforcement works for methods that declare a permission. The problem is that sensitive onboarding methods declare neither the attribute nor their own object-level check.
The dispatcher, up close
The controller at app/Domain/Api/Controllers/Jsonrpc.php resolves the RPC method string to a service class with reflection, then calls PermissionEnforcer::enforce right before invoking:
$serviceName = "Leantime\\Domain\\{$module}\\Services\\{$service}";
// ...
$this->permissionEnforcer->enforce($serviceName, $methodName, $paramsFromRequest);
$method_response = app()->make($serviceName)->$methodName(...$preparedParams);
The comment above the enforce call in the source says a #[RequiresPermission] on the resolved method is checked before the call. That describes the intent. It cannot make the attribute exist. An @api-tagged method that lacks both the attribute and an equivalent in-body authorization check needs careful review because the dispatcher does not protect it.
Parameters bind by name through prepareParameters. It ignores request keys the signature does not declare and casts the ones it does. That matters for array-typed parameters, where the caller splices keys into what later becomes an ORM update:
foreach ($methodParams as $methodParam) {
$required = ! $methodParam->isDefaultValueAvailable();
// ... collect $filtered_parameters[$position], casting scalars
// and enums to the declared type.
}
Authentication to /api/jsonrpc accepts a Bearer token, and authenticated users can create personal access tokens from their settings. An ordinary web session also works for an AJAX request carrying X-Requested-With: XMLHttpRequest; a session cookie alone on a non-AJAX API request is insufficient because that path uses an in-memory session. The vulnerable calls work from a readonly account, the lowest role in the system.
One missing attribute
The worst case sits in the onboarding flow, at app/Domain/Auth/Services/Onboarding.php:227:
/**
* saveAccount - first onboarding step: validates the chosen password,
* assembles the user record from the submitted profile fields and
* persists it.
*
* @api
*/
public function saveAccount(array $userInvite, string $name,
string $jobTitle, string $password): string
{
// ...
$userInvite['status'] = 'i';
$userInvite['user'] = $userInvite['username'];
$userInvite['password'] = $password;
session(['tempPassword' => $password]);
if ($this->userService->editUser($userInvite, $userInvite['id'])) {
return 'saved';
}
// ...
}
It is @api, so the RPC dispatcher routes to it. It has no #[RequiresPermission]. It calls Users::editUser, which declares its own attribute on the service method. But attributes do not fire on nested calls. With no guard on saveAccount, the inner call is unguarded too, and editUser writes whatever the array carries:
$updateData = [
'username' => $values['user'],
// ...
'status' => $values['status'],
'role' => $values['role'],
// ...
'clientId' => $values['clientId'],
// ... password hashed via password_hash() when plaintext
];
Nothing validates role. Roles::$roleKeys maps 50 to owner:
private static array $roleKeys = [
5 => 'readonly',
10 => 'commenter',
20 => 'editor',
30 => 'manager',
40 => 'admin',
50 => 'owner',
];
So from a readonly account, one request can rewrite another user, including an owner, with an attacker-chosen password and role. This example targets user ID 1 from the local fixture:
{"jsonrpc":"2.0","id":1,
"method":"leantime.rpc.auth.onboarding.saveAccount",
"params":{"userInvite":{"id":1,"username":"admin@example.com",
"role":50,"clientId":1},
"name":"Pwned Admin","jobTitle":"x",
"password":"Str0ng!Pass"}}
(Sent as a POST /api/jsonrpc with an Authorization: Bearer header.)
Response: {"result":"saved"}. The target now has the planted password and role 50. The role field is a second problem on top of the missing ownership check: even an account editing itself should not be able to assign itself the owner role. Neither condition is checked in this path.
There is one wrinkle worth stating precisely. saveAccount forces status = 'i'. Leantime’s local password login filters on LOWER(status) = 'a', so the target must also be activated. The sibling leantime.rpc.auth.onboarding.completeOnboarding method is exposed without an authorization check and writes status = 'A' when handed the full user record. Activation happens before its automatic-login step. On ordinary API requests, the temporary password does not persist between calls, so the RPC may report an error after the database update. The target record and a fresh login must be checked separately.
How far the chain reaches
With local password login enabled and 2FA disabled, the two database updates let the attacker log in as the modified owner. If the target has 2FA enabled, the separately unguarded leantime.rpc.twoFA.twoFA.disable2FA method accepts the target userId and clears its 2FA state. The result is owner-level application access, including the registered permissions to administer users and projects.
Not just one method
The same root cause, @api with no attribute and no in-body check, recurs across the codebase:
- TwoFA, any user.
TwoFA::{getSetupData, saveSecret, verifyAndEnable, disable2FA}atapp/Domain/TwoFA/Services/TwoFA.php:41,71,88,111accept a caller-supplieduserIdwithout checking ownership.getSetupDatareturns the stored TOTP secret when one exists, or a newly generated enrollment secret otherwise; it omits the QR code when 2FA is already enabled.disable2FAclears the target’s 2FA state. - Cross-tenant reads.
Projects::getAllUsers()atProjects.php:2862in v3.9.8 andProjects.php:2963in the checkedmasterrevision returns user details across client boundaries, excluding API-source accounts.Projects::getAllProjects()returns project metadata across clients, excluding closed projects under the repository’s default filter.Timesheets::{getLoggedHoursForTicketByDate, getSumLoggedHoursForTicket, getRemainingHours}return ticket-level aggregate hours without a project-membership check. They do not return individual timesheet records. - Notification forgery.
Notifications::addNotifications(array)atNotifications.php:51in v3.9.8 andNotifications.php:52in the checkedmasterrevision writes attacker-controlled message and URL rows into any user’s inbox. The payload renders as a clickable notification with an attacker domain. - Infrastructure triggers.
Cron::runScheduledTasks()invokesschedule:run, which evaluates due jobs, andQueue::processQueue(Workers $worker)processes queued work for a selected worker. The actual effects depend on configured jobs and queued messages.
The local assessment notes record successful calls for the 2FA, inventory, notification, scheduler, and queue paths. The source review above narrows what each method actually returns or executes. These methods reinforce the need for a full RPC authorization audit, but the account takeover is the primary finding and basis for the score.
Scoring it
The framework flaw, a fail-open attribute enforcer, is not exploitable on its own. The methods that trust it are. Score the concrete chain:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 8.8
The rating scope stays unchanged. FIRST guidance defines scope as crossing a security authority boundary. The attacker ends as instance owner inside the application, which means full read and write of Leantime’s data and the ability to delete projects and users, but not demonstrated access to the underlying operating system. That is a confidentiality, integrity, and availability high on the application’s authority, with no proven crossing beyond it.
The precondition is one low-priv account. On instances where directory users are auto-provisioned as Leantime accounts, that is any member of the directory; on instances that invite users explicitly, it is any invitee. It does not need an admin to act, and after the takeover it abuses only features the owner legitimately has.
What to do about it
Upgrade when a fixed release lands. At the time of writing, current master (commit 056835f1, fetched 2026-09-12) still contains the vulnerable pattern in every location above.
Adding attributes alone is insufficient for the onboarding path. The server should resolve the invited user from a validated invitation token instead of trusting a submitted user record. The affected methods also need object-level checks on internal-call paths, and privileged fields such as role, clientId, target ID, and activation state must not be caller-selectable during onboarding. Cross-tenant reads should check membership or an appropriate administrative permission.
Until then, a few compensating controls are worth having:
- Add CSRF protection to the browser-session RPC path as defense in depth. This does not stop an authenticated low-privilege caller using an API credential.
- If you deploy behind a proxy, restrict
/api/jsonrpcto known internal callers. - Audit every
@apimethod that lacks an attribute or equivalent in-body authorization, and explicitly allowlist methods intended for all authenticated users.
Disclosure timeline
- August 2, 2026: Reported the finding through Leantime’s private GitHub Security Advisory form.
- August 16, 2026: Sent a follow-up report to Leantime’s published security email address.
- September 20, 2026: Published after more than 48 days without an evaluation or expected resolution date.
We will not publish the working account-takeover scripts until a fix is available. The maintainers can obtain the reproduction material through the private advisory.
The general lesson outlives this bug. An attribute that only guards when present is not a control. A dispatcher that fail-opens on a missing one invites any refactor, any new service, any forgotten docblock to expose itself silently. The codebase looked protected. It was not.
Research and write-up by the Fenko team. Runtime testing was local against a self-built v3.9.8 Docker instance; the cited master revision was checked by source review.
