DEV Community

Santosh Kumar Puppala
Santosh Kumar Puppala

Posted on

How one Owner could take over any account in another company — cross-tenant IDOR in InvoiceShelf (CVE-2026-55610)

TL;DR

  • InvoiceShelf (self-hosted, multi-company invoicing) let any company Owner read and overwrite any user in any other company on the same installation.
  • GET/PUT /api/v1/users/{id} resolved the user by global primary key, and the policy only checked that you own your header-company — never that the target belonged to it. Result: cross-tenant account takeover (overwrite email + password, and re-assign the victim into your company as super-admin).
  • High, CVSS 8.7 (CWE-639). Fixed in 2.4.1. I reported it and am credited as Reporter — CVE-2026-55610.

Why you should care

Multi-tenant apps live or die on one invariant: tenant A can never touch tenant B's data. InvoiceShelf enforced that on the delete path but quietly forgot it on read and update of users — the highest-value object in the app. That one asymmetry turned "manage my company's users" into "manage everyone's."

The setup

InvoiceShelf is a Laravel app where each request carries a company header, and a middleware pins that header to one of the requester's companies. Users are managed through a standard resource route.

The bug (source → sink)

The route uses implicit route-model binding — Laravel resolves {user} straight from the global users table by primary key:

// routes/api.php
Route::apiResource('/users', UsersController::class); // auth:sanctum + company + bouncer

// UsersController.php
public function show(User $user) { ... }                     // $user bound by GLOBAL primary key
public function update(UserRequest $r, User $user) { ... }
Enter fullscreen mode Exit fullscreen mode

Authorization is delegated to UserPolicy, which leans on isOwner():

// UserPolicy.php
public function view(User $me, User $target)   { if ($me->isOwner()) return true; /* ... */ }
public function update(User $me, User $target) { if ($me->isOwner()) return true; /* ... */ }

// User.php
public function isOwner() {
    return Company::find(request()->header('company'))->owner_id === $this->id;
}
Enter fullscreen mode Exit fullscreen mode

isOwner() only asks "is the requester the owner of the company in their own header?" — it never checks that $target belongs to that company. So an Owner of Company A sails through the check for a user who lives entirely in Company B.

How the account takeover works

The "aha"

The check verified who you are, never who the target is — so isOwner() was a global skeleton key, not a tenant boundary.

Proof of concept (benign)

Company A (owner: alice), Company B (owner: bob); carol (id 7) belongs to Company B only.

# as alice, header  company: A
GET  /api/v1/users/7                                        -> 200, returns carol (Company B)
PUT  /api/v1/users/7  { email, password,
                        companies: [{ id: A, role: "super admin" }] }   -> 200
# carol's email + password overwritten; carol re-synced into Company A as super-admin

# control: a non-owner member of A
GET  /api/v1/users/7                                        -> 403
# the route IS access-controlled; the bug is specifically the missing target-company check for owners
Enter fullscreen mode Exit fullscreen mode

The fix

Version 2.4.1 scopes the check to the requester's company — exactly the way the delete() path already did (its code even carried the comment "so a user from one company cannot delete accounts belonging to another"). After the owner check, verify the target belongs to the active company ($target->hasCompany($company) / User::whereCompany(...)), or constrain the route-model binding to the active company.

Takeaways

  • Read and write paths need the same tenant scoping. A guard on delete means nothing if view/update skip it — attackers just pick the unguarded verb.
  • Implicit route-model binding by global PK is a footgun in multi-tenant apps. Bind within the tenant scope, or you've shipped a global object lookup.
  • "Am I an owner?" is not "am I allowed to touch this object?" Authorize the object, not just the role.

Disclosure timeline

  • 2026-06-13 — reported privately via GitHub private vulnerability reporting.
  • Fixed in 2.4.1; advisory GHSA-vgx6-6cqr-m8qr published; CVE-2026-55610 assigned; credited as Reporter.

Advisory: GHSA-vgx6-6cqr-m8qr / CVE-2026-55610 (High, 8.7). Thanks to the maintainer for the quick fix.


Your turn: how do you keep read and write authorization in sync across a multi-tenant app — shared policy helpers, a global tenant scope, tests that assert 403 across tenants? Curious what's worked for you.

Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq and Veridor). GitHub: @Santoshkumarpuppala.

Top comments (0)