Summary
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 / getManualRates read 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.
Summary
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 coarseaccess_settingpermission, without being an admin and without anydelete_*/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/Indexuses->authorize('delete_inventories'), andOrder/Detailgates mutating actions withedit_orders).Affected components
Settings\Zones\ZoneShippingOptionspackages/admin/src/Livewire/Components/Settings/Zones/ZoneShippingOptions.php:47delete→CarrierOption::query()->find($arguments['id'])->delete()(id is client-supplied)Settings\Zones\Detailpackages/admin/src/Livewire/Components/Settings/Zones/Detail.php:46delete→DeleteActionon the boundZoneSettings\Taxes\Detailpackages/admin/src/Livewire/Components/Settings/Taxes/Detail.php:42delete→DeleteActionon the boundTaxZoneSettings\Taxes\TaxRatespackages/admin/src/Livewire/Components/Settings/Taxes/TaxRates.php:97delete→DeleteActionon aTaxRateEach file contains zero
authorizecalls, 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/updaterequests, the action executes purely on the page-levelaccess_settinggate — there is no per-resource permission, anddelete_zones/delete_taxespermissions 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: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.Result:
The CONTROL case rules out a false positive: the same harness correctly denies
Order/Detail::markPaidfor a user lackingedit_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:
CarrierOption→ that shipping rate disappears from checkout for the zone.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).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'), butzoneis a#[Computed]method (not a property), so it throwsReflectionExceptionafter 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 inSettings/Locations/Index.phpandTeam/RolePermission.php:Apply to the
delete(andedit) actions in all four components. Consider also generating granular*_zones/*_taxespermissions so settings access can follow least privilege, and fix the$this->reset('zone')call inZones\Detail.