Skip to content

FMCS Bypass on Requested-Assets Listing (Cross-Company Read)

Moderate
snipe published GHSA-fxm8-w79r-g7wr Aug 24, 2026

Package

composer snipe/snipe-it (Composer)

Affected versions

=<8.6.3

Patched versions

8.7.0

Description

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

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Improper Access Control

The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. Learn more on MITRE.

Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. Learn more on MITRE.

Credits