GHSA-F7H9-QV4X-9X57
Vulnerability from github – Published: 2026-09-11 21:28 – Updated: 2026-09-11 21:28Summary
Four Livewire components in the Settings area expose destructive Filament actions (delete / edit) that perform no server-side authorization. Any authenticated user who can reach the Settings pages — i.e. holding only the coarse access_setting permission, without being an admin and without any delete_*/edit_* permission — can delete tax zones, tax rates, shipping zones, and carrier (shipping-rate) options by invoking the component action directly over the Livewire endpoint.
These records sit on the storefront checkout path, so deleting them breaks shipping-rate calculation, removes region-scoped payment methods, and corrupts tax resolution at checkout.
This is inconsistent with the rest of the admin, where destructive actions are gated by granular permissions (e.g. Settings/Locations/Index uses ->authorize('delete_inventories'), and Order/Detail gates mutating actions with edit_orders).
Affected components
| Component | File | Unauthorized action |
|---|---|---|
Settings\Zones\ZoneShippingOptions |
packages/admin/src/Livewire/Components/Settings/Zones/ZoneShippingOptions.php:47 |
delete → CarrierOption::query()->find($arguments['id'])->delete() (id is client-supplied) |
Settings\Zones\Detail |
packages/admin/src/Livewire/Components/Settings/Zones/Detail.php:46 |
delete → DeleteAction on the bound Zone |
Settings\Taxes\Detail |
packages/admin/src/Livewire/Components/Settings/Taxes/Detail.php:42 |
delete → DeleteAction on the bound TaxZone |
Settings\Taxes\TaxRates |
packages/admin/src/Livewire/Components/Settings/Taxes/TaxRates.php:97 |
delete → DeleteAction on a TaxRate |
Each file contains zero authorize calls, and the actions declare neither ->authorize() nor an enforced ->visible() guard.
Details
The Settings pages mount these as child Livewire components. The parent page authorizes access_setting (e.g. Pages/Settings/Taxes.php:29), but the child components do not re-check authorization, and their destructive actions carry no ->authorize(). Because each Livewire component handles its own /livewire/update requests, the action executes purely on the page-level access_setting gate — there is no per-resource permission, and delete_zones / delete_taxes permissions are never even generated by the seeder (packages/admin/database/seeders/PermissionsTableSeeder.php).
ZoneShippingOptions::deleteAction() is the clearest case — it deletes by an id taken straight from the client action arguments with no scoping and no permission check:
// packages/admin/src/Livewire/Components/Settings/Zones/ZoneShippingOptions.php
public function deleteAction(): Action
{
return Action::make('delete')
->requiresConfirmation()
// ... no ->authorize(), no ->visible()
->action(function (array $arguments): void {
CarrierOption::query()->find($arguments['id'])->delete(); // client-controlled id
// ...
});
}
Proof of Concept
Confirmed with the project's own test harness (Pest + Orchestra Testbench, SQLite) — the real Livewire/Filament code path, executed as a non-admin user holding only access_setting.
use Livewire\Livewire;
use Shopper\Core\Models\{CarrierOption, Zone};
use Shopper\Livewire\Components\Settings\Zones\ZoneShippingOptions;
use Tests\Core\Stubs\User;
uses(Tests\Admin\TestCase::class);
it('low-priv access_setting user deletes a CarrierOption with no authorization', function (): void {
$attacker = User::factory()->create();
$attacker->givePermissionTo('access_setting'); // NOT admin, NO delete_* permission
$this->actingAs($attacker, config('shopper.auth.guard'));
$zone = Zone::factory()->create();
$option = CarrierOption::factory()->create(['zone_id' => $zone->id]);
Livewire::test(ZoneShippingOptions::class, ['selectedZoneId' => $zone->id])
->callAction('delete', arguments: ['id' => $option->id]);
expect(CarrierOption::query()->find($option->id))->toBeNull(); // deleted -> vulnerable
});
Result:
Attacker: isAdmin()=false, can('access_setting')=true, can('delete_zones')=false, can('edit_zones')=false
[BEFORE] CarrierOption count = 1 (target #1 'DHL Express' exists = YES)
[ATTACK] callAction('delete', id=1) on ZoneShippingOptions
[AFTER ] CarrierOption count = 0 (target #1 exists = NO -> deleted)
PASS 3 passed (11 assertions)
✓ CONTROL — Order/Detail::markPaid is correctly hidden without edit_orders (harness enforces declared authz)
✓ a CarrierOption is deleted by the low-priv user
✓ a shipping Zone is deleted by the low-priv user
The CONTROL case rules out a false positive: the same harness correctly denies Order/Detail::markPaid for a user lacking edit_orders, proving authorization is enforced when a component declares it — these four components simply declare none.
Impact
A low-privileged staff member (or a compromised low-privileged account) can sabotage the storefront's checkout/revenue path without any delete permission:
- Delete a
CarrierOption→ that shipping rate disappears from checkout for the zone. - Delete a
Zone→ removes the country → carrier/payment-method/currency mapping; customers shipping to those countries lose all shipping and payment options (CarrierRateService::getRatesForZone/getManualRatesread these directly). - Delete a
TaxZone/TaxRate→TaxCalculator::resolveZone()can no longer resolve the zone, corrupting tax calculation at checkout.
Net effect: integrity and availability damage to live commerce configuration, performed by a principal who was never granted that authority (least-privilege violation).
Secondary issue found while reproducing
Zones\Detail::deleteAction()->after() calls $this->reset('zone'), but zone is a #[Computed] method (not a property), so it throws ReflectionException after the row is deleted. Worth fixing alongside the authorization gap.
Suggested remediation
Add an authorization check to each action, and ideally a mount() guard on each child component, matching the pattern already used in Settings/Locations/Index.php and Team/RolePermission.php:
public function deleteAction(): Action
{
return Action::make('delete')
->authorize('access_setting') // or a new granular delete_zones / delete_taxes permission
->requiresConfirmation()
// ...
}
Apply to the delete (and edit) actions in all four components. Consider also generating granular *_zones / *_taxes permissions so settings access can follow least privilege, and fix the $this->reset('zone') call in Zones\Detail.
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "shopper/framework"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0"
},
{
"fixed": "2.9.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-56826"
],
"database_specific": {
"cwe_ids": [
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-11T21:28:20Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nFour Livewire components in the Settings area expose destructive Filament actions (`delete` / `edit`) that perform **no server-side authorization**. Any authenticated user who can reach the Settings pages \u2014 i.e. holding only the coarse `access_setting` permission, **without** being an admin and **without** any `delete_*`/`edit_*` permission \u2014 can delete tax zones, tax rates, shipping zones, and carrier (shipping-rate) options by invoking the component action directly over the Livewire endpoint.\n\nThese records sit on the storefront checkout path, so deleting them breaks shipping-rate calculation, removes region-scoped payment methods, and corrupts tax resolution at checkout.\n\nThis is inconsistent with the rest of the admin, where destructive actions are gated by granular permissions (e.g. `Settings/Locations/Index` uses `-\u003eauthorize(\u0027delete_inventories\u0027)`, and `Order/Detail` gates mutating actions with `edit_orders`).\n\n## Affected components\n\n| Component | File | Unauthorized action |\n|---|---|---|\n| `Settings\\Zones\\ZoneShippingOptions` | `packages/admin/src/Livewire/Components/Settings/Zones/ZoneShippingOptions.php:47` | `delete` \u2192 `CarrierOption::query()-\u003efind($arguments[\u0027id\u0027])-\u003edelete()` (id is client-supplied) |\n| `Settings\\Zones\\Detail` | `packages/admin/src/Livewire/Components/Settings/Zones/Detail.php:46` | `delete` \u2192 `DeleteAction` on the bound `Zone` |\n| `Settings\\Taxes\\Detail` | `packages/admin/src/Livewire/Components/Settings/Taxes/Detail.php:42` | `delete` \u2192 `DeleteAction` on the bound `TaxZone` |\n| `Settings\\Taxes\\TaxRates` | `packages/admin/src/Livewire/Components/Settings/Taxes/TaxRates.php:97` | `delete` \u2192 `DeleteAction` on a `TaxRate` |\n\nEach file contains **zero** `authorize` calls, and the actions declare neither `-\u003eauthorize()` nor an enforced `-\u003evisible()` guard.\n\n## Details\n\nThe Settings pages mount these as child Livewire components. The parent page authorizes `access_setting` (e.g. `Pages/Settings/Taxes.php:29`), but the child components do not re-check authorization, and their destructive actions carry no `-\u003eauthorize()`. Because each Livewire component handles its own `/livewire/update` requests, the action executes purely on the page-level `access_setting` gate \u2014 there is no per-resource permission, and `delete_zones` / `delete_taxes` permissions are never even generated by the seeder (`packages/admin/database/seeders/PermissionsTableSeeder.php`).\n\n`ZoneShippingOptions::deleteAction()` is the clearest case \u2014 it deletes by an id taken straight from the client action arguments with no scoping and no permission check:\n\n```php\n// packages/admin/src/Livewire/Components/Settings/Zones/ZoneShippingOptions.php\npublic function deleteAction(): Action\n{\n return Action::make(\u0027delete\u0027)\n -\u003erequiresConfirmation()\n // ... no -\u003eauthorize(), no -\u003evisible()\n -\u003eaction(function (array $arguments): void {\n CarrierOption::query()-\u003efind($arguments[\u0027id\u0027])-\u003edelete(); // client-controlled id\n // ...\n });\n}\n```\n\n## Proof of Concept\n\nConfirmed with the project\u0027s own test harness (Pest + Orchestra Testbench, SQLite) \u2014 the real Livewire/Filament code path, executed as a non-admin user holding only `access_setting`.\n\n```php\nuse Livewire\\Livewire;\nuse Shopper\\Core\\Models\\{CarrierOption, Zone};\nuse Shopper\\Livewire\\Components\\Settings\\Zones\\ZoneShippingOptions;\nuse Tests\\Core\\Stubs\\User;\n\nuses(Tests\\Admin\\TestCase::class);\n\nit(\u0027low-priv access_setting user deletes a CarrierOption with no authorization\u0027, function (): void {\n $attacker = User::factory()-\u003ecreate();\n $attacker-\u003egivePermissionTo(\u0027access_setting\u0027); // NOT admin, NO delete_* permission\n $this-\u003eactingAs($attacker, config(\u0027shopper.auth.guard\u0027));\n\n $zone = Zone::factory()-\u003ecreate();\n $option = CarrierOption::factory()-\u003ecreate([\u0027zone_id\u0027 =\u003e $zone-\u003eid]);\n\n Livewire::test(ZoneShippingOptions::class, [\u0027selectedZoneId\u0027 =\u003e $zone-\u003eid])\n -\u003ecallAction(\u0027delete\u0027, arguments: [\u0027id\u0027 =\u003e $option-\u003eid]);\n\n expect(CarrierOption::query()-\u003efind($option-\u003eid))-\u003etoBeNull(); // deleted -\u003e vulnerable\n});\n```\n\nResult:\n\n```\nAttacker: isAdmin()=false, can(\u0027access_setting\u0027)=true, can(\u0027delete_zones\u0027)=false, can(\u0027edit_zones\u0027)=false\n[BEFORE] CarrierOption count = 1 (target #1 \u0027DHL Express\u0027 exists = YES)\n[ATTACK] callAction(\u0027delete\u0027, id=1) on ZoneShippingOptions\n[AFTER ] CarrierOption count = 0 (target #1 exists = NO -\u003e deleted)\n\nPASS 3 passed (11 assertions)\n \u2713 CONTROL \u2014 Order/Detail::markPaid is correctly hidden without edit_orders (harness enforces declared authz)\n \u2713 a CarrierOption is deleted by the low-priv user\n \u2713 a shipping Zone is deleted by the low-priv user\n```\n\nThe CONTROL case rules out a false positive: the same harness correctly denies `Order/Detail::markPaid` for a user lacking `edit_orders`, proving authorization is enforced when a component declares it \u2014 these four components simply declare none.\n\n## Impact\n\nA low-privileged staff member (or a compromised low-privileged account) can sabotage the storefront\u0027s checkout/revenue path without any delete permission:\n\n- **Delete a `CarrierOption`** \u2192 that shipping rate disappears from checkout for the zone.\n- **Delete a `Zone`** \u2192 removes the country \u2192 carrier/payment-method/currency mapping; customers shipping to those countries lose all shipping and payment options (`CarrierRateService::getRatesForZone` / `getManualRates` read these directly).\n- **Delete a `TaxZone` / `TaxRate`** \u2192 `TaxCalculator::resolveZone()` can no longer resolve the zone, corrupting tax calculation at checkout.\n\nNet effect: integrity and availability damage to live commerce configuration, performed by a principal who was never granted that authority (least-privilege violation).\n\n## Secondary issue found while reproducing\n\n`Zones\\Detail::deleteAction()-\u003eafter()` calls `$this-\u003ereset(\u0027zone\u0027)`, but `zone` is a `#[Computed]` method (not a property), so it throws `ReflectionException` **after** the row is deleted. Worth fixing alongside the authorization gap.\n\n## Suggested remediation\n\nAdd an authorization check to each action, and ideally a `mount()` guard on each child component, matching the pattern already used in `Settings/Locations/Index.php` and `Team/RolePermission.php`:\n\n```php\npublic function deleteAction(): Action\n{\n return Action::make(\u0027delete\u0027)\n -\u003eauthorize(\u0027access_setting\u0027) // or a new granular delete_zones / delete_taxes permission\n -\u003erequiresConfirmation()\n // ...\n}\n```\n\nApply to the `delete` (and `edit`) actions in all four components. Consider also generating granular `*_zones` / `*_taxes` permissions so settings access can follow least privilege, and fix the `$this-\u003ereset(\u0027zone\u0027)` call in `Zones\\Detail`.",
"id": "GHSA-f7h9-qv4x-9x57",
"modified": "2026-09-11T21:28:20Z",
"published": "2026-09-11T21:28:20Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/shopperlabs/shopper/security/advisories/GHSA-f7h9-qv4x-9x57"
},
{
"type": "PACKAGE",
"url": "https://github.com/shopperlabs/shopper"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Shopping privilege escalation through missing authorization in Settings components"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.