The Profile Method That Would Edit Anyone: An IDOR in Leantime's JSON-RPC
A function named editOwn tells you what it is for. It edits your own profile. Your name, your phone number, your password. Read it in the codebase and there is nothing to think about, which is exactly why it is worth thinking about.
In Leantime, the open-source project management platform, that method was safe through the browser and wide open over the API. Not because it was written twice, but because the one thing making it safe was not part of the method at all.
The short version
Leantime's editOwn is reachable over a JSON-RPC endpoint that lets the caller supply the user id being edited. Nothing on that path compares the id to the person making the request. Send the instance owner's id and a password of your choosing, then log in as them. A read-only account was enough. CVSS 8.8, rising to 9.8 on instances with self-registration. Fixed in v3.9.0.
Already fixed
Upgrade to v3.9.0 or later. If you are self-hosting and still on 3.7.x or 3.8.0, this is the one to act on, because there is no configuration you can change to close it.
Two doors to the same room
Leantime does the sensible thing in its web controller. When you save your profile, the controller decides which record you are editing by reading it out of your session:
// The user id is taken from the session, not from the request.
$this->userId = session('userdata.id');
Whatever the browser sends is ignored. You cannot ask to edit somebody else, because you were never asked which account you meant. That is the correct pattern, and it is the reason the browser path is fine.
Then there is the second door. Leantime exposes a JSON-RPC endpoint at /api/jsonrpc that reaches the internal service layer directly. The dispatcher pulls the service out of the container and calls the method with whatever parameters came in the request body. That is one line of code, and it never passes through the controller.
So the id the controller was so careful about is now just a number in some JSON, chosen by whoever sent the request.
The service takes that id and hands it to the repository. The repository writes the row it names, hashing and storing a new password if one was supplied. At no point between the socket and the database does anything ask whether the caller is allowed to touch that row.
The exploit is a single request
Log in as anybody. A contractor. A client invited into one project. A read-only stakeholder. On instances with self-registration enabled, an account you created yourself thirty seconds ago.
Then send one JSON-RPC call naming user id 1, which in a default install is the instance owner, with a password you choose. Log in through the normal login form as the owner. That is the whole attack. No chaining, no race, no victim interaction, nothing the victim can decline.
One detail turns it from loud to quiet: set the email field to the victim's existing address. The account stays reachable at the address the attacker already knows, and nothing visible changes until someone tries to log in and finds their password no longer works.
The lesson: RPC dissolves controller-level authorization
This is the part that generalizes past Leantime, and it is why an otherwise unremarkable IDOR was worth writing up.
A lot of applications put their authorization in controllers. It is a natural place for it. The controller is where the request arrives, where the session is available, where you already know who is calling. Checks accumulate there over years, and the codebase gets safer.
Then somebody adds an RPC endpoint that exposes the service layer, because API clients need to reach the same functionality and the services are right there, already written, already tested. It is a reasonable engineering decision.
And in that moment, every authorization check that lived in a controller quietly stops being a control. Not removed, not weakened, just bypassed by a route that does not go through the place where the checks live. The services were never the security boundary. They were the thing behind the security boundary. Exposing them directly moves the boundary without moving the checks.
Self-service methods take the worst of it. Anything named editOwn, updateMyProfile, changeMyPassword was written under an assumption that is now false: that the id it operates on could only ever have been the caller's. That assumption was true when a controller supplied the id from the session. Over RPC it is just an input.
If you run an application with an RPC or GraphQL surface over an existing service layer, the question to ask is not "are our services tested." It is: which of our authorization checks live in controllers, and does the RPC path go through them? Where the answer is no, every self-service method is a candidate for exactly this bug.
What happened after the report
I reported this in April as one finding inside a broader look at Leantime's JSON-RPC authorization surface, filed privately through GitHub's advisory process. The maintainers credited me as reporter the same day, which I took as a good sign. Then the thread went quiet. I asked twice in May for confirmation of receipt, tagging the maintainers directly. I followed up with the project's security contact after its stated 48-hour window passed. Nothing came back, so I opened a CERT/CC coordination case.
On May 27 the maintainer closed the advisory. No triage discussion, no questions, no response to any of the three findings in it. Six days later, the code was fixed. I asked on the closed thread in June for a CVE to be assigned. That went unanswered too. In July I opened a second advisory to track this finding on its own.
Every one of those records is still private today. Both GitHub advisories return 404 to anyone outside the repository, and CERT/CC has not published its note despite an expected date in July. The reporter credit is invisible for the same reason — it sits on a record nobody outside the repository can read. There is no public record of this bug anywhere except the page you are reading. The closed advisory even still says "Patched versions: None" and "No known CVE," two months after the fix shipped.
In June the bug was fixed anyway. Not through a security advisory, and not with any note that it had been reported. The fix landed inside a feature pull request titled after a permission-engine refactor and shipped in v3.9.0. The service method now discards the caller-supplied id and pins the write to the session user, and the release put a permission layer across the service tier so authorization no longer depends on which entry point a request arrives through. Structurally, that is the right place for it.
The code, in other words, is fine now. The disclosure is not. This was a silent patch, and silent patching deserves to be called what it is: a bad practice that shifts risk onto the people least equipped to carry it.
In July, four Leantime JSON-RPC issues were published as CVEs by other researchers. This one is not among them. Different method, different weakness, different outcome. The v3.9.0 release notes name several IDOR fixes; the self-service password overwrite is not one of them.
Why a quiet fix is a problem
A patched bug with no identifier is invisible to the people who most need to see it.
Think about who runs a self-hosted project management tool. Often a small team, often without a dedicated security function, often pinned to a version because upgrading is a Saturday job nobody wants. That team's upgrade signal is a CVE feed, a Dependabot alert, or a release note that says "security fix." An account-takeover path that closes inside a pull request titled after a refactor produces none of those.
So an administrator still on 3.8.0 today has no way to learn that the release they skipped closed a hole letting any account on their instance seize the owner's login. The code is fixed. The knowledge is not distributed. Those are different things, and only one of them protects anybody.
That is most of why this write-up exists. A carve-out CVE has been requested through CERT/CC so the finding gets an identifier that reaches the feeds administrators actually watch. Until it does, this page is the notice.
Disclosure timeline
- April 23, 2026 Found through source review and reproduced against a live instance. Reported privately to the vendor through GitHub's advisory process.
- May 9, 2026 Followed up after the vendor's stated 48-hour response window passed. No reply.
- May 16, 2026 CERT/CC coordination case opened (VU#685483).
- May 27, 2026 The maintainer closed the advisory, without triage discussion or any response to the findings.
- June 2, 2026 Fixed upstream six days later, inside a pull request titled after a refactor. No security advisory, and no changelog entry naming the issue.
- June 12, 2026 v3.9.0 released containing the fix.
- July 6, 2026 Four adjacent Leantime JSON-RPC CVEs published by third parties. This issue is not among them.
- July 18, 2026 Opened a second advisory to track the editOwn takeover on its own, separate from the closed report.
- August 12, 2026 Carve-out CVE requested through CERT/CC for the editOwn takeover.
- August 18, 2026 This write-up. Both GitHub advisories and the CERT/CC note confirmed still private.
For the vulnerable code path, the CVSS reasoning, and the audit steps for operators who ran an affected version, see the full advisory.
Ask which door your checks are standing in
Authorization that lives in a controller protects the requests that go through that controller. Add an API over the same services and the checks stay where they were. Finding that gap means tracing a request end to end and asking what each layer actually verifies, which is how we test APIs and web applications for clients across the Charlotte, NC area and nationwide.
Get a Quote