GHSA-F7H9-QV4X-9X57

Vulnerability from github – Published: 2026-09-11 21:28 – Updated: 2026-09-11 21:28
VLAI
Summary
Shopping privilege escalation through missing authorization in Settings components
Details

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.

Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    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.