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
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-checkeddeleted_atafter 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 activeassigned_to/assigned_typevalue 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'swithoutGlobalScopes()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:
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
Affected Versions
Confirmed at Snipe-IT v8.6.3 and the
developbranch prior to the fix. Present since the API checkout controllers began usingwithoutGlobalScopes()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):app/Http/Controllers/Api/ComponentsController::checkout:app/Http/Controllers/Api/ConsumablesController::checkout: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. Nodeleted_atcheck 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 plainModel::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
Where asset id 6 is soft-deleted. Before the fix:
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
Where user id 11 is soft-deleted. Before the fix:
The pivot row
consumables_usersnow references a trashed user id.Fix
The fix applies at two layers:
Layer 1:
exists_undeletedvalidation ruleNew custom rule in
app/Providers/ValidationServiceProvider.php, mirroring the existingunique_undeletedshape:Wired into
AssetCheckoutRequest:And into
AccessoryCheckoutRequest:Requests with soft-deleted targets are now rejected at FormRequest validation time.
Layer 2: post-
withoutGlobalScopesdeleted_at guard in each controllerApi/AssetsController::checkoutafter the target resolution block:Api/ComponentsController::checkoutafter the asset resolution:Api/ConsumablesController::checkoutafter the user resolution:The trashed target then falls through to the existing "target does not exist" error branch. Preserves the intended FMCS error-messaging behavior of the
withoutGlobalScopescall while closing the soft-delete leak.Regression tests
tests/Feature/Checkouts/Api/CheckoutToSoftDeletedTargetTest.php(new file) covers eight scenarios:Recommended Follow-Up Hardening
Not required for this fix, but worth considering:
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 explicitdeleted_atcheck.exists_undeletedto 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:
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