June 17, 2026
A walk-through of a High-severity cross-company account-takeover bug in a widely-used open-source…
TL;DR — In InvoiceShelf (a self-hosted, open-source invoicing app built on Laravel), any user who was the Owner of one company could read…
Santosh Kumar Puppala
4 min read
- 1 A walk-through of a High-severity cross-company account-takeover bug in a widely-used open-source invoicing platform — how I found it, why it happened, and what it teaches about securing multi-tenant software.
- – Background
- – The vulnerability
- – The tell
- – Proof of concept (from the published advisory)
A walk-through of a High-severity cross-company account-takeover bug in a widely-used open-source invoicing platform — how I found it, why it happened, and what it teaches about securing multi-tenant software.
TL;DR — In InvoiceShelf (a self-hosted, open-source invoicing app built on Laravel), any user who was the Owner of one company could read and overwrite any user account in any other company on the same installation — including resetting their email and password and moving them into the attacker's company as a super admin. That's full cross-tenant account takeover. It's now tracked as CVE-2026–55610 (CVSS 8.7, High), fixed in 2.4.1.
Background
Multi-tenant apps carry an implicit promise: my data is mine, your data is yours, and the wall between us holds even when we share the same server. Self-hosted SaaS like InvoiceShelf lets one installation host multiple "companies." The whole model depends on every object-level operation asking two questions, not one:
- Is this user allowed to perform this action?
- Does the object they're acting on actually belong to their tenant?
This bug is what happens when an app asks the first question and forgets the second.
The vulnerability
The user-management endpoints looked like this:
GET /api/v1/users/{user}
PUT /api/v1/users/{user}GET /api/v1/users/{user}
PUT /api/v1/users/{user}In Laravel, {user} uses implicit route-model binding — the framework resolves the User straight from the URL by global primary key. There was no scoping to the caller's company on that lookup.
Authorization was delegated to UserPolicy, whose view() and update() methods boiled down to:
if ($user->isOwner()) {
return true;
}if ($user->isOwner()) {
return true;
}And isOwner() checked only the requester's own company header:
// User::isOwner()
Company::find(request()->header('company'))->owner_id == $this->id;// User::isOwner()
Company::find(request()->header('company'))->owner_id == $this->id;So the policy confirmed "you are the owner of the company in your request header" — and then happily let you read or overwrite a user from a completely different company, because nothing ever compared the target user's company to yours.
A PUT could overwrite the victim's name, email, and password, and even re-sync them into the attacker's company with a super-admin role. Read access leaks PII; write access is account takeover.
The tell
What made this satisfying was the asymmetry. The sibling destroy() method was correctly scoped:
// destroy() — scoped, with a comment explaining exactly why
User::whereCompany(...) // "so a user from one company cannot delete accounts belonging to another"// destroy() — scoped, with a comment explaining exactly why
User::whereCompany(...) // "so a user from one company cannot delete accounts belonging to another"The developers had clearly understood the cross-tenant risk — for deletes. The view and update paths simply never got the same guard. When one of a set of sibling endpoints enforces a check and the others don't, that gap is one of the highest-signal things you can find in a code review.
Proof of concept (from the published advisory)
With Company A (owner alice) and Company B (owner bob), and carol (user id 7) belonging only to B:
- As alice with header
company: A,GET /api/v1/users/7→ 200, returning carol — a user from another company. - As alice,
PUT /api/v1/users/7with a new email, password, andcompanies:[{id: A, role: "super admin"}]→ 200. Carol's credentials are overwritten and she's pulled into Company A as super admin. - Control: a non-owner member of A gets 403 — proving the route is access-controlled in general; the defect is specifically the missing target-company check for owners.
Why this matters beyond one app
Self-hosted, multi-tenant business software runs the back offices of countless small companies, nonprofits, and public-interest organizations. A cross-tenant account-takeover flaw doesn't just affect one user — it breaks the isolation guarantee that every tenant on the server is relying on. And because the software is open source and freely deployed, a single coordinated fix propagates to every operator who updates. Hardening widely-used open-source software is, in practice, supply-chain and infrastructure security: the benefit is broad, public, and compounding.
The fix
InvoiceShelf 2.4.1 adds the missing check: after confirming the caller is an owner, verify the target user actually belongs to the caller's company — mirroring the scoping the destroy() path already had. If you run InvoiceShelf, upgrade to 2.4.1 or later.
Lessons for builders
- Role checks are not object checks. "Is this user an owner?" and "Does this object belong to this user's tenant?" are different questions. Object-level authorization (the heart of IDOR / CWE-639) needs the second one, every time.
- Framework conveniences can be footguns. Implicit route-model binding that resolves by global primary key is a recurring source of IDOR. Constrain bindings to the active tenant, or add a global scope.
- Audit sibling endpoints together. If
deleteis scoped butread/updatearen't, that inconsistency is the bug. Diff how each verb on the same resource enforces authorization. - Comments are clues. The "so a user from one company cannot delete accounts belonging to another" comment told me the team already knew the threat model — which made the missing checks elsewhere stand out.
I reported this privately through the project's GitHub security advisory process. The maintainer triaged it, shipped a fix in 2.4.1, published the advisory, and a CVE was assigned — a textbook coordinated-disclosure cycle. My thanks to the InvoiceShelf maintainer for the fast, professional response.
This is part of an ongoing program of work in which I identify and responsibly disclose authorization and business-logic vulnerabilities in widely-used open-source software, with a particular focus on civic-technology and critical-infrastructure platforms. I'll be publishing more of these writeups.
- Advisory: https://github.com/InvoiceShelf/InvoiceShelf/security/advisories/GHSA-vgx6-6cqr-m8qr
- CVE: CVE-2026–55610 · CWE-639 · CVSS 8.7 (High) · Fixed in 2.4.1
I'm Santosh Kumar Puppala, a security researcher focused on authorization and business-logic flaws in open-source software used by businesses, governments, and NGOs. You can find me on GitHub at https://github.com/Santoshkumarpuppala.
#Cybersecurity #ApplicationSecurity #IDOR #Laravel #CVE #OpenSource #SupplyChainSecurity #ResponsibleDisclosure