When Full Multiple Company Support (FMCS) was enabled, GET /hardware/requested returned every pending asset request in the installation regardless of the caller's company scope. Any authenticated user holding the routine assets.view permission saw the full cross-tenant list of pending requests, disclosing the requested asset name, the requester's display name and profile link, the location, and the expected check-in date for every other company's records. No parameter was needed. A single unmodified GET request returned the entire table.
Status
Already fixed on the develop branch in commit fbf441f2a2 on 2026-06-22 ("Item Requests: Fixed FD-56095 - apply FMCS to requestable items listing"). The fix has not yet shipped in a tagged release. v8.6.3 was tagged on 2026-06-15, one week before the fix landed. The first tagged release containing the fix will be v8.6.4 or v8.7.0, whichever cuts first.
Severity
Medium. CVSS 3.1 base score 5.0 with vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N.
Component reasoning:
| Metric |
Value |
Reasoning |
| Attack Vector |
Network |
Standard authenticated web request. |
| Attack Complexity |
Low |
No special conditions. A single GET returns the entire cross-tenant table. |
| Privileges Required |
Low |
Requires an authenticated user with assets.view, a routine permission granted to any staff member expected to see the asset catalog. |
| User Interaction |
None |
Attacker acts alone. |
| Scope |
Changed |
FMCS is Snipe-IT's tenant-isolation boundary. Reading records from a different company than the attacker's own crosses the app's own security domain. |
| Confidentiality |
Low |
Cross-tenant disclosure of pending request contents: asset name, requesting user's display name and profile link, location, expected check-in date. Not full record dumps, but structured PII plus operational intent. |
| Integrity |
None |
The endpoint is read-only. |
| Availability |
None |
No availability impact. |
CWE Classification
- Primary: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor). The endpoint returned cross-tenant records to a caller who should only see their own company's data.
- Related: CWE-863 (Incorrect Authorization). An authorization check ran (
authorize('index', Asset::class)) but resolved to the flat assets.view permission without a per-record company scope, because the class-string argument short-circuited past the SnipePermissionsPolicy::before() FMCS branch.
- Parent: CWE-284 (Improper Access Control).
Affected Versions
Confirmed present in Snipe-IT versions up to and including v8.6.3. Fixed on develop in commit fbf441f2a2 on 2026-06-22. Not yet in a tagged release at the time of this advisory. Any FMCS-enabled installation running v8.6.3 or earlier is affected.
Details
Root cause: two facts combining
Fact 1: CheckoutRequest has no CompanyableScope. The model at app/Models/CheckoutRequest.php is a plain extends Model with no company scope trait, so CheckoutRequest::with(...)->whereNull('canceled_at')->get() returns rows for every company.
Fact 2: The authorize call passed a class string. getRequestedIndex() called $this->authorize('index', Asset::class). Because the second argument is a class string rather than a model instance, SnipePermissionsPolicy::before() returned early at app/Policies/SnipePermissionsPolicy.php:61:
if (! $item instanceof Model) {
return;
}
The subsequent FMCS check Company::isCurrentUserHasAccess($item) at line 69 was skipped. Authorization collapsed to whichever permission the policy method mapped to (assets.view), with no per-record company scoping.
The vulnerable method
app/Http/Controllers/Assets/AssetsController.php::getRequestedIndex() prior to the fix:
public function getRequestedIndex($user_id = null)
{
$this->authorize('index', Asset::class);
$requestedItems = CheckoutRequest::with('user', 'requestedItem')
->whereNull('canceled_at')->with('user', 'requestedItem');
if ($user_id) {
$requestedItems->where('user_id', $user_id)->get();
}
$requestedItems = $requestedItems->orderBy('created_at', 'desc')->get();
return view('hardware/requested', compact('requestedItems'));
}
Two independent problems the reporter identified:
- No FMCS scope applied to
$requestedItems.
- The optional
$user_id filter was silently broken. The ->get() inside the if block ran the query and discarded the result. The next line re-ran the query without the filter. The reporter's aside on this was correct.
The view then rendered every returned request row unconditionally, showing the asset link, the requesting user's display name and profile link, the location, and the expected check-in date.
Rendering the leaked data
resources/views/hardware/requested.blade.php iterates $requestedItems and renders each request's requested asset, requester, location, and expected check-in without further filtering.
Proof of Concept
From the researcher's report. Setup: FMCS enabled. Attacker is a standard user belonging only to Company A with assets.view. Company B has pending asset requests created by Company B users.
GET /hardware/requested HTTP/1.1
Host: target
Cookie: snipeit_session=ATTACKER_COMPANY_A_SESSION
The rendered page lists every pending request across all companies. Each row shows the requested asset name and link, its location, the expected check-in date, and the requesting user's display name and profile link. No parameter tampering required.
Fix
Applied in commit fbf441f2a2. The fixed method:
public function getRequestedIndex($user_id = null)
{
$this->authorize('index', Asset::class);
$requestedItems = CheckoutRequest::with('user', 'requestedItem')->whereNull('canceled_at');
if ($user_id) {
$requestedItems->where('user_id', $user_id);
}
$requestedItems = $requestedItems->orderBy('created_at', 'desc')->get();
if (Company::isFullMultipleCompanySupportEnabled() && ! auth()->user()->isSuperUser()) {
$requestedItems = $requestedItems->filter(
fn (CheckoutRequest $request) => $request->requestable
&& Company::isCurrentUserHasAccess($request->requestable)
)->values();
}
return view('hardware/requested', compact('requestedItems'));
}
Two changes:
- Added a post-fetch FMCS filter through
Company::isCurrentUserHasAccess($request->requestable), the same pivot-aware check the rest of the codebase uses. Requests whose requestable (the asset the user asked for) belongs to a company the current user does not have access to are dropped from the result set before rendering.
- Removed the stray
->get() from the if ($user_id) branch. The where('user_id', $user_id) now actually applies to the collection returned on line below.
The authorize('index', Asset::class) call is kept as an intentional coarse gate (any user viewing the page needs at least assets.view). The per-record company scoping is now enforced by the post-fetch filter instead of relying on the policy before() method, which cannot help when the ability is checked against a class string.
Regression tests
The fix commit shipped with tests/Feature/Requests/Ui/AssetRequestIndexTest.php (112 lines) locking in the FMCS scoping behavior for the requested-assets index.
Recommended Follow-Up Hardening
Not required for this fix, but worth considering:
- Add
CompanyableScope (or the trait that installs it) to CheckoutRequest so raw CheckoutRequest::all() and CheckoutRequest::find($id) cannot cross tenant boundaries in future call sites. Defense in depth at the query layer, in addition to the current defense at the controller layer.
- Sweep other
authorize(..., Model::class) calls in the codebase. Any authorize against a class string that is intended to guard cross-company access needs a separate per-record check because SnipePermissionsPolicy::before()'s FMCS branch requires a model instance. The getRequestedIndex() pattern is worth documenting as a caution for future contributors.
- Consider a lint rule or convention doc note: "class-level authorize gates the permission, not the tenant. Add an explicit per-record
Company::isCurrentUserHasAccess() check when returning collections."
Workarounds Before Upgrading
If upgrading to the fixed release is not immediately feasible in an FMCS-enabled deployment:
- Revoke
assets.view from user accounts that should not have cross-company visibility. Note this is broader than the vulnerability requires, since assets.view gates many legitimate reads.
- Redirect or block
/hardware/requested at the web-server layer for non-superadmin users if operations can tolerate that. The endpoint has no API equivalent, so a targeted redirect is feasible.
Timeline
- 2026-06-17: Internal discovery of the vulnerable pattern. Ticket FD-56095 opened.
- 2026-06-15:
v8.6.3 tagged (still vulnerable).
- 2026-06-22: Fix committed as
fbf441f2a2 on the develop branch.
- 2026-07-15: External researcher submitted a report describing the same vulnerability, unaware of the internal fix that had already landed on develop.
Credit
Internal discovery via ticket FD-56095. Independently reported by external researcher dangbaokhoa (GitHub) after the fix had already landed on develop but before it shipped in a tagged release. Reporter's writeup was thorough, correctly identified the root cause including the class-string / before() interaction, and additionally flagged the broken $user_id filter as a bonus. Credit reflected in the advisory even though the fix predated the external report.
References
When Full Multiple Company Support (FMCS) was enabled,
GET /hardware/requestedreturned every pending asset request in the installation regardless of the caller's company scope. Any authenticated user holding the routineassets.viewpermission saw the full cross-tenant list of pending requests, disclosing the requested asset name, the requester's display name and profile link, the location, and the expected check-in date for every other company's records. No parameter was needed. A single unmodified GET request returned the entire table.Status
Already fixed on the
developbranch in commitfbf441f2a2on 2026-06-22 ("Item Requests: Fixed FD-56095 - apply FMCS to requestable items listing"). The fix has not yet shipped in a tagged release.v8.6.3was tagged on 2026-06-15, one week before the fix landed. The first tagged release containing the fix will bev8.6.4orv8.7.0, whichever cuts first.Severity
Medium. CVSS 3.1 base score 5.0 with vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N.Component reasoning:
assets.view, a routine permission granted to any staff member expected to see the asset catalog.CWE Classification
authorize('index', Asset::class)) but resolved to the flatassets.viewpermission without a per-record company scope, because the class-string argument short-circuited past theSnipePermissionsPolicy::before()FMCS branch.Affected Versions
Confirmed present in Snipe-IT versions up to and including
v8.6.3. Fixed ondevelopin commitfbf441f2a2on 2026-06-22. Not yet in a tagged release at the time of this advisory. Any FMCS-enabled installation runningv8.6.3or earlier is affected.Details
Root cause: two facts combining
Fact 1:
CheckoutRequesthas noCompanyableScope. The model atapp/Models/CheckoutRequest.phpis a plainextends Modelwith no company scope trait, soCheckoutRequest::with(...)->whereNull('canceled_at')->get()returns rows for every company.Fact 2: The authorize call passed a class string.
getRequestedIndex()called$this->authorize('index', Asset::class). Because the second argument is a class string rather than a model instance,SnipePermissionsPolicy::before()returned early atapp/Policies/SnipePermissionsPolicy.php:61:The subsequent FMCS check
Company::isCurrentUserHasAccess($item)at line 69 was skipped. Authorization collapsed to whichever permission the policy method mapped to (assets.view), with no per-record company scoping.The vulnerable method
app/Http/Controllers/Assets/AssetsController.php::getRequestedIndex()prior to the fix:Two independent problems the reporter identified:
$requestedItems.$user_idfilter was silently broken. The->get()inside theifblock ran the query and discarded the result. The next line re-ran the query without the filter. The reporter's aside on this was correct.The view then rendered every returned request row unconditionally, showing the asset link, the requesting user's display name and profile link, the location, and the expected check-in date.
Rendering the leaked data
resources/views/hardware/requested.blade.phpiterates$requestedItemsand renders each request's requested asset, requester, location, and expected check-in without further filtering.Proof of Concept
From the researcher's report. Setup: FMCS enabled. Attacker is a standard user belonging only to Company A with
assets.view. Company B has pending asset requests created by Company B users.The rendered page lists every pending request across all companies. Each row shows the requested asset name and link, its location, the expected check-in date, and the requesting user's display name and profile link. No parameter tampering required.
Fix
Applied in commit
fbf441f2a2. The fixed method:Two changes:
Company::isCurrentUserHasAccess($request->requestable), the same pivot-aware check the rest of the codebase uses. Requests whose requestable (the asset the user asked for) belongs to a company the current user does not have access to are dropped from the result set before rendering.->get()from theif ($user_id)branch. Thewhere('user_id', $user_id)now actually applies to the collection returned on line below.The
authorize('index', Asset::class)call is kept as an intentional coarse gate (any user viewing the page needs at leastassets.view). The per-record company scoping is now enforced by the post-fetch filter instead of relying on the policybefore()method, which cannot help when the ability is checked against a class string.Regression tests
The fix commit shipped with
tests/Feature/Requests/Ui/AssetRequestIndexTest.php(112 lines) locking in the FMCS scoping behavior for the requested-assets index.Recommended Follow-Up Hardening
Not required for this fix, but worth considering:
CompanyableScope(or the trait that installs it) toCheckoutRequestso rawCheckoutRequest::all()andCheckoutRequest::find($id)cannot cross tenant boundaries in future call sites. Defense in depth at the query layer, in addition to the current defense at the controller layer.authorize(..., Model::class)calls in the codebase. Any authorize against a class string that is intended to guard cross-company access needs a separate per-record check becauseSnipePermissionsPolicy::before()'s FMCS branch requires a model instance. ThegetRequestedIndex()pattern is worth documenting as a caution for future contributors.Company::isCurrentUserHasAccess()check when returning collections."Workarounds Before Upgrading
If upgrading to the fixed release is not immediately feasible in an FMCS-enabled deployment:
assets.viewfrom user accounts that should not have cross-company visibility. Note this is broader than the vulnerability requires, sinceassets.viewgates many legitimate reads./hardware/requestedat the web-server layer for non-superadmin users if operations can tolerate that. The endpoint has no API equivalent, so a targeted redirect is feasible.Timeline
v8.6.3tagged (still vulnerable).fbf441f2a2on thedevelopbranch.Credit
Internal discovery via ticket FD-56095. Independently reported by external researcher dangbaokhoa (GitHub) after the fix had already landed on develop but before it shipped in a tagged release. Reporter's writeup was thorough, correctly identified the root cause including the class-string /
before()interaction, and additionally flagged the broken$user_idfilter as a bonus. Credit reflected in the advisory even though the fix predated the external report.References