Menu::getItems() static cache is keyed on neither user nor area — admin sidebar silently loses items; cross-user leak under persistent runtimes

Author: CVS007Created Aug 19, 2026Updated Aug 26, 2026
LabelsFix Proposed

Bug report

Issue Description

Webkul\Core\Menu::getItems() memoises its result into a static $items local. The cache is keyed on nothing — not the $area argument, not the authenticated user — so the first caller in a PHP process fixes the menu for every later caller in that process.

Two consequences:

  1. An administrator's sidebar silently loses items. If any earlier call in the same process resolved the menu for a lower-privileged user, the admin gets that user's menu. This may explain intermittent "menu items missing" reports that don't reproduce on demand — it depends entirely on call ordering within a process.
  2. getItems('customer') after getItems('admin') returns the admin menu. The $area argument is ignored once the cache is warm, even though the method throws when $area is absent.

Because it is keyed on neither dimension, this looks like an oversight rather than a deliberate trade-off — which is why the suggested fix below is just "key it properly" rather than a redesign.

Under PHP-FPM the impact is limited: one request per process, so the cache lives exactly one request, and the ordering problem only surfaces where a single request resolves the menu for more than one user. Under a persistent runtime — Laravel Octane, Swoole, RoadRunner, or a long-lived queue worker rendering mail — the cache survives across requests per worker, and the same code becomes a cross-user menu leak until the worker recycles.

This is not a security vulnerability and I'm not reporting it as one. Route authorisation is enforced separately by Admin\Http\Middleware\Bouncer through acl()->getRoles(), and it is unaffected — a user who sees a leaked menu entry still receives a 401 on the route. This is presentation-layer only: the sidebar shows entries the viewer cannot use, or hides entries they can.

Preconditions

1. Laravel 12 / PHP 8.3 / MySQL 8.0 — Krayin v2.2.0 (Webkul\Core\Core::KRAYIN_VERSION)
2. Commit id: de2c37a6

Steps to reproduce

Does not require Octane. Any single PHP process that resolves the menu for two users shows it, so plain artisan is enough — the mechanism is identical to what an Octane worker exhibits across requests.

Given two users: an admin on a role with permission_type = all, and a staff user on a custom role that does not hold the leads permission.

1. Resolve the menu as the staff user
2. Resolve the menu as the admin, in the same process
3. Compare — and then repeat with the order reversed
php
$admin = User::find(1);
$staff = User::where('email', '[email protected]')->first();

auth()->guard('user')->setUser($staff);
$staffKeys = collect(menu()->getItems('admin'))->map(fn ($i) => $i->getKey());

auth()->guard('user')->setUser($admin);
$adminKeys = collect(menu()->getItems('admin'))->map(fn ($i) => $i->getKey());

The offending lines, packages/Webkul/Core/src/Menu.php:

php
public function getItems(?string $area = null, string $key = ''): Collection
{
    if (! $area) {
        throw new \Exception('Area must be provided to get menu items.');
    }

    static $items;

    if ($items) {
        return $items;          // first caller's menu, returned to everyone
    }

    // ... per-user filter below is correct; the cache around it is not
    $this->configMenu = $configMenu
        ->filter(fn ($item) => bouncer()->hasPermission($item['key']))
        ->toArray();

The bouncer()->hasPermission() filter itself is correct and per-user. It simply only runs once per process.

Expected result

Each call reflects the currently authenticated user and the requested area:

  • staff → dashboard, mail, activities, contacts, configuration
  • admin → dashboard, leads, quotes, mail, activities, contacts, products, settings, configuration

Actual result

Both calls return whichever menu was built first.

Staff first, then admin — the admin's sidebar is truncated to the staff menu:

1) as STAFF  -> dashboard, mail, activities, contacts, configuration
2) as ADMIN  -> dashboard, mail, activities, contacts, configuration

Reversed, admin first, then staff — the staff user receives the admin sidebar including leads, while bouncer()->hasPermission('leads') correctly returns false for them:

1) as ADMIN  -> dashboard, leads, quotes, mail, activities, contacts, products, settings, configuration
2) as STAFF  -> dashboard, leads, quotes, mail, activities, contacts, products, settings, configuration

It also affects test suites: any Pest/PHPUnit run where one test authenticates and renders a page, then another asserts on a different user's menu, receives the first user's menu. A correct menu-visibility test in our suite passed in isolation and failed as soon as it ran alongside another test.

Suggested fix

Not opening a PR unsolicited — glad to if it would help, and equally glad to be told the current behaviour is intended. Options, cheapest first:

  1. Key the cache by area and user. static $items = [] indexed on $area.':'.(auth()->guard('user')->id() ?? 'guest'). Smallest change that fixes both the user and the $area problem, and keeps the optimisation.
  2. Drop the memoisation. getItems() does a config() read plus a filter and is called a handful of times per request; the cache may not be buying much.
  3. Move it off static onto the singleton, plus a flush() method, so Octane's RequestReceived listener can clear it — the pattern Octane documents for stateful singletons.

Workaround

Assert on bouncer()->hasPermission($key) rather than menu()->getItems() — it is the exact predicate getItems() filters each entry on, and carries no cache.