Skip to content

API Checkout Endpoints Bind Live Inventory to Soft-Deleted Targets

Moderate
snipe published GHSA-qffp-xpqv-gqr4 Aug 24, 2026

Package

composer snipe/snipe-it (Composer)

Affected versions

=<8.6.3

Patched versions

8.7.0

Description

Three Snipe-IT API checkout endpoints resolved their target with Model::withoutGlobalScopes()->find(...), which explicitly bypasses Laravel's SoftDeletes global scope. None of them post-checked deleted_at after the lookup, so a user with the relevant checkout permission could bind live inventory to a soft-deleted user, asset, or location. The write succeeded, and the deleted target row was then referenced by an active assigned_to / assigned_type value that would not be visible to normal listings.

The affected endpoints were:

  • POST /api/v1/hardware/{id}/checkout (user, asset, and location targets)
  • POST /api/v1/components/{id}/checkout (asset target)
  • POST /api/v1/consumables/{id}/checkout (user target)

The web-side equivalents were not vulnerable because they use plain Model::find(), which respects the SoftDeletes scope. The API's withoutGlobalScopes() was added deliberately to improve FMCS mismatch error messages, but the post-lookup deleted-target check was never added back.

Severity

Medium. CVSS 3.1 base score 5.4 with vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L.

Component reasoning:

Metric Value Reasoning
Attack Vector Network Standard REST API.
Attack Complexity Low Single POST call, no special conditions.
Privileges Required Low Requires the relevant checkout permission for the resource (asset, consumable, or component). Routine permission for IT operators.
User Interaction None Attacker acts alone.
Scope Unchanged Impact stays inside Snipe-IT's data.
Confidentiality None No data disclosure.
Integrity Low Live inventory records get FK-style references to soft-deleted rows, corrupting the assignment ledger, audit trails, reports, and any cleanup workflow that assumes trashed targets have no live references.
Availability Low Reports and downstream workflows that dereference the pivot rows may error or produce inconsistent results. Not a full outage.

The Snipe-IT project treats this as Medium rather than Low because it corrupts the primary asset ledger, which is Snipe-IT's core business function.

CWE Classification

  • Primary: CWE-20 (Improper Input Validation). The checkout target id from the request is not validated against soft-delete state before being written.
  • Related: CWE-460 (Improper Cleanup on Thrown Exception) / CWE-707 (Improper Neutralization) patterns apply loosely, but the more informative classification is that soft-delete lifecycle boundary is not enforced at the input boundary.
  • Parent: CWE-284 (Improper Access Control) in the sense that the trashed-row soft delete is a lifecycle access boundary that the endpoints failed to enforce.

Affected Versions

Confirmed at Snipe-IT v8.6.3 and the develop branch prior to the fix. Present since the API checkout controllers began using withoutGlobalScopes() for FMCS error handling.

Details

Vulnerable target resolution

The three API controllers resolved targets like this:

app/Http/Controllers/Api/AssetsController::checkout (v8.6.3):

if (request('checkout_to_type') == 'location') {
    $target = Location::withoutGlobalScopes()->find(request('assigned_location'));
    ...
} elseif (request('checkout_to_type') == 'asset') {
    $target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
    ...
} elseif (request('checkout_to_type') == 'user') {
    $target = User::withoutGlobalScopes()->find(request('assigned_user'));
    ...
}

app/Http/Controllers/Api/ComponentsController::checkout:

$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));

app/Http/Controllers/Api/ConsumablesController::checkout:

if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
    return response()->json(...);
}

withoutGlobalScopes() was added intentionally to allow the controller to distinguish "target does not exist at all" from "target is in a different company" for FMCS error messaging. The intention was reasonable, but the same call also bypassed the SoftDeletes global scope. No deleted_at check was added after the lookup, so trashed targets were treated as valid checkout destinations.

The web-side equivalents (Assets\AssetCheckoutController, Consumables\ConsumableCheckoutController, Components\ComponentCheckoutController) use plain Model::find() and are not affected.

Reporter's unverified fifth case

The reporter successfully demonstrated the asset-to-user, asset-to-asset, component-to-asset, and consumable-to-user cases. They noted that they attempted asset-to-location but the test was blocked by an FMCS company mismatch in their reproduction environment. Inspection of the code confirmed the fifth path is structurally identical to the other two asset target paths and IS exploitable in a matching-company or non-FMCS deployment. The fix covers it and there is a regression test for it.

Proof of Concept

From the researcher's report. As an operator with checkout permission:

Case A: asset to soft-deleted asset

POST /api/v1/hardware/3/checkout
Authorization: Bearer $TOKEN
Content-Type: application/json

{"checkout_to_type": "asset", "assigned_asset": 6, "note": "verification"}

Where asset id 6 is soft-deleted. Before the fix:

HTTP/1.1 200 OK
{"status":"success","messages":"Asset checked out successfully.","payload":{"asset":"LAB-DELUSER-71761704"}}

Live asset id 3 now has assigned_to = 6, assigned_type = 'App\\Models\\Asset' pointing at a trashed row.

Case B: consumable to soft-deleted user

POST /api/v1/consumables/1/checkout
{"assigned_to": 11, "checkout_qty": 1, "note": "verification"}

Where user id 11 is soft-deleted. Before the fix:

HTTP/1.1 200 OK
{"status":"success","messages":"Consumable checked out successfully.","payload":null}

The pivot row consumables_users now references a trashed user id.

Fix

The fix applies at two layers:

Layer 1: exists_undeleted validation rule

New custom rule in app/Providers/ValidationServiceProvider.php, mirroring the existing unique_undeleted shape:

Validator::extend('exists_undeleted', function ($attribute, $value, $parameters, $validator) {
    if (count($parameters) < 1) {
        return false;
    }
    $column = $parameters[1] ?? 'id';

    return DB::table($parameters[0])
        ->where($column, '=', $value)
        ->whereNull('deleted_at')
        ->exists();
});

Wired into AssetCheckoutRequest:

'assigned_user'     => 'numeric|nullable|required_without_all:...|exists_undeleted:users,id',
'assigned_asset'    => 'numeric|nullable|required_without_all:...|exists_undeleted:assets,id',
'assigned_location' => 'numeric|nullable|required_without_all:...|exists_undeleted:locations,id',

And into AccessoryCheckoutRequest:

'assigned_user'     => 'required_without_all:...|nullable|exists_undeleted:users,id',
'assigned_asset'    => 'required_without_all:...|nullable|exists_undeleted:assets,id',
'assigned_location' => 'required_without_all:...|nullable|exists_undeleted:locations,id',

Requests with soft-deleted targets are now rejected at FormRequest validation time.

Layer 2: post-withoutGlobalScopes deleted_at guard in each controller

Api/AssetsController::checkout after the target resolution block:

if (isset($target) && ! empty($target->deleted_at)) {
    $target = null;
}

Api/ComponentsController::checkout after the asset resolution:

if ($asset && ! empty($asset->deleted_at)) {
    $asset = null;
}

Api/ConsumablesController::checkout after the user resolution:

if ($user && ! empty($user->deleted_at)) {
    $user = null;
}

The trashed target then falls through to the existing "target does not exist" error branch. Preserves the intended FMCS error-messaging behavior of the withoutGlobalScopes call while closing the soft-delete leak.

Regression tests

tests/Feature/Checkouts/Api/CheckoutToSoftDeletedTargetTest.php (new file) covers eight scenarios:

  • Asset checkout rejects soft-deleted user target
  • Asset checkout rejects soft-deleted asset target
  • Asset checkout rejects soft-deleted location target (the reporter's fifth case)
  • Consumable checkout rejects soft-deleted user target
  • Component checkout rejects soft-deleted asset target
  • Accessory checkout rejects soft-deleted user target (defense in depth via the new validation rule)
  • Asset checkout still succeeds with a live user target (happy-path guard against over-blocking)
  • Consumable checkout still succeeds with a live user target (happy-path guard)

Recommended Follow-Up Hardening

Not required for this fix, but worth considering:

  • Sweep all API controllers for other uses of withoutGlobalScopes() on user-provided ids. Any lookup that reaches an authorization or persistence sink should either respect the SoftDeletes scope OR be paired with an explicit deleted_at check.
  • Add exists_undeleted to the ordinary Snipe-IT contributor documentation as the preferred rule for validating any user-controlled id that references a soft-deletable table, so future endpoints inherit the correct default.

Workarounds Before Upgrading

If upgrading immediately is not feasible, an admin can periodically audit the pivot tables and asset assignment columns for references to trashed rows:

-- Consumable checkouts pointing at trashed users
SELECT cu.id, cu.consumable_id, cu.assigned_to
FROM consumables_users cu
JOIN users u ON u.id = cu.assigned_to
WHERE u.deleted_at IS NOT NULL;

-- Component checkouts pointing at trashed assets
SELECT ca.id, ca.component_id, ca.asset_id
FROM components_assets ca
JOIN assets a ON a.id = ca.asset_id
WHERE a.deleted_at IS NOT NULL;

-- Assets checked out to trashed targets
SELECT a.id, a.asset_tag, a.assigned_type, a.assigned_to
FROM assets a
LEFT JOIN users u  ON a.assigned_type = 'App\\Models\\User'     AND u.id = a.assigned_to
LEFT JOIN assets t ON a.assigned_type = 'App\\Models\\Asset'    AND t.id = a.assigned_to
LEFT JOIN locations l ON a.assigned_type = 'App\\Models\\Location' AND l.id = a.assigned_to
WHERE (u.deleted_at IS NOT NULL) OR (t.deleted_at IS NOT NULL) OR (l.deleted_at IS NOT NULL);

Any hit is either historical exploitation or normal wear caused by an admin trashing a target that had live assignments. Clean up by checking the affected inventory back in.

Credit

Vulnerability reported by an external security researcher.

References

Fixed

Fixed in #19330

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
Unchanged
Confidentiality
None
Integrity
Low
Availability
Low

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:U/C:N/I:L/A:L

CVE ID

No known CVE

Weaknesses

No CWEs

Credits