Vulnerability DatabaseGHSA-7rj2-q7vr-2299

GHSA-7rj2-q7vr-2299: 
PHP vulnerability analysis and mitigation

Summary

Locker::handle() returns $next($request) for every request that Livewire reports as a Livewire request, before it reads the lockscreen session flag. Livewire decides that only from the client-sent X-Livewire header. As a result, while a session is locked:

  1. every Livewire component update of a panel page that is still open in the browser (another tab, or the page shown again with the Back button) keeps running - table search and pagination, record modals, form saves, delete actions;
  2. any panel page is rendered normally when the request carries X-Livewire: 1. Only plain page navigation is redirected to the lock screen.

Details

src/Http/Middleware/Locker.php (v3.4.1, also current 3.x e2db5ad), lines 22-24:

if ($this->isLivewireRequest()) {
    return $next($request);
}

isLivewireRequest() calls LivewireManager::isLivewireRequest(), which in Livewire 3 and 4 is request()->hasHeader('X-Livewire'). The plugin registers Locker as persistent auth middleware ($panel->authMiddleware([Locker::class], true) in Lockscreen::register()). Livewire therefore re-runs it on every POST /livewire/update, with a fake request carrying the method and path of the page the component lives on (PersistentMiddleware::makeFakeRequest()); a redirect returned there aborts the update (Utils::applyMiddleware()). That is exactly the mechanism the lock needs - but the early return above skips the lock check on all of these requests, because the real request carries the X-Livewire header. The lock screen itself is not affected by a fix: its route (lockscreen.{panel}.page) only has the panel's base middleware, not authMiddleware, so Locker never runs for the lock screen's own Livewire updates.

Fix

Enforce the lock for Livewire requests as well; keep not counting them as idle activity:

if ($this->isLivewireRequest()) {
    if ($this->isLocked($request) && $this->isSegmentMatched($request)) {
        $panelId = filament()->getCurrentPanel()?->getId();
        return to_route("lockscreen.{$panelId}.page");
    }
    return $next($request);
}

plus a small isLocked() helper shared with the existing GET check (same truthiness as before). The patch below also adds a first Pest + Orchestra Testbench test suite (tests/) with four tests: a locked session redirects a normal visit, a visit with X-Livewire, and a Livewire action of an already open page; actions still run while unlocked. Before the fix two of the four fail, after the fix all four pass (see "Tests" below).

Tests

Run in a container on PHP 8.3.36 (Pest 3.8.7 / PHPUnit 11.5.56, Orchestra Testbench 10, Filament 5.11.1, Livewire 4.4.8), with your existing phpunit.xml.dist:

  • before the fix: Tests: 2 failed, 2 passed - "does not let the X-Livewire header skip the lockscreen" and "blocks Livewire actions of an already open page while locked" get 200 instead of the redirect;
  • after the fix: Tests: 4 passed (11 assertions);
  • Pint (--test) on the changed files: clean except one existing issue in lines this patch does not touch (blank_line_before_statement in the idle branch); Rector --dry-run on Locker.php: no changes.
  • Not run: Filament 4 / Livewire 3, other PHP versions, the 2.x branch. PHPUnit warns that phpunit.xml.dist uses a deprecated schema (left unchanged). Related, not part of this report: the idle timeout only fires on the next full page load, so a page left open past the timeout stays usable until then; closing that would need a client-side timer (a design choice for you).

Proof of concept

Laravel 13.40.0, Filament 5.11.1, Livewire 4.4.8, filament-lockscreen 3.4.1, PHP 8.3.36, in a container without network; plugin set up as in the README (Lockscreen::make()->enablePlugin()), one test page with a counter action. Requests go through the real HTTP kernel:

== 1. log in and open the panel page (unlocked baseline)
   GET /admin/secret: 200, marker shown: yes
   Livewire action while unlocked: 200, actionCount=1
== 2. lock the session with the package's own route (POST /admin/lock-session)
   302 -> /admin/screen/lock, session lockscreen=true
== 3. CONTROL: normal browser GET of the panel page while locked
   302 -> /admin/screen/lock => blocked (lock works)
== 4. BYPASS A: same GET with header 'X-Livewire: 1' while locked
   200, marker shown: yes => page rendered despite lock
== 5. BYPASS B: Livewire action from the already-open page while locked
   200, actionCount=2 => action ran despite lock

Manual check in a browser: lock the screen, press Back (or switch to another tab of the panel) and use a table search or an action on that page - it works. Or, in the developer tools console: fetch('/admin', {headers: {'X-Livewire': '1'}}) returns the dashboard HTML.

Impact

Someone with physical access to an unattended computer on which a panel user has locked the screen (manually or via the idle timeout) can keep using the panel with that user's permissions: read records, edit, delete, run actions. No tools are needed for the open-page route; the header route needs the browser's developer tools. No privilege escalation beyond the locked-out user, no remote attack without the victim's browser or session cookie. The lock screen is a convenience control and the OS lock remains the stronger one, hence Moderate rather than High. <details><summary>Plain-language explanation</summary>

What the package does

Filament is a popular admin panel for Laravel websites. This plugin adds a lock screen to it: when you step away from your desk you click "Lock", and the panel shows a password prompt instead of your data. You stay logged in, so you do not lose your work - but anyone who walks up to your computer has to know your password to get back in. It can also lock by itself after a period of inactivity. The lock is a note in your session on the server ("this session is locked"). A piece of code called the Locker middleware looks at every request and, when the note is there, sends the browser to the lock screen instead of the page it asked for.

Where it goes wrong

Filament pages are built with Livewire. A page loads once; after that, every click inside it (search a table, open a record, save a form, delete a row) is a small background request to the server. Livewire marks those background requests with a label, the X-Livewire header. The Locker middleware starts with: "Is this a Livewire request? Then let it through." - and it does that before it checks whether the session is locked. Two consequences:

  1. Every click inside a page that is still open keeps working while the screen is "locked".
  2. The label is just a header that the browser sends. Anyone can add it to an ordinary page request, and the page is then shown normally, lock or no lock.

What happens, step by step

  1. Alice works in the admin panel, clicks Lock and goes for coffee. Her tab shows the lock screen.
  2. Bob sits down at her computer and presses the browser's Back button (or switches to another tab of the same panel). Browsers often show the previous page again from memory - issue #90 on GitHub reported exactly that.
  3. Bob uses that page: searches the table, pages through records, opens and edits them, runs actions. Each of those is a Livewire request, and the lock lets every one of them through.
  4. Or Bob opens the browser's developer tools and loads any panel page with the X-Livewire header added: the full page comes back. Only a normal page load (typing an address, clicking a regular link) is sent to the lock screen.

An analogy

An office locks its front door when the receptionist leaves, but staff badges still open the side door - and the side door does not check the badge, it only checks that the visitor is wearing a lanyard.

How it was checked

A small Laravel 13 + Filament 5 + Livewire 4 site was built in an isolated container with no network, the plugin installed exactly as its README says (version 3.4.1, the latest). A test user logged in, opened a test page, and locked the session with the plugin's own "Lock" button route. Then:

  • a normal visit to the test page -> sent to the lock screen (the lock works here),
  • the same visit with the X-Livewire header -> the test page was shown,
  • a button on the test page that was already open -> its action ran (a counter went from 1 to 2). The "secret" was a fixed marker text on our own test page; nothing was attacked. A one-line check anyone can do on their own site: lock the screen, press Back, and use a search box or button on the page that reappears. With the bug it works; with the fix you are sent to the lock screen.

Potential abuse

ConcernReal?Why
Someone at an unattended computer uses the panel although it is lockedYesBack button or another open tab is enough; no tools needed. Everything the logged-in user may do is possible: read records, edit, delete, run actions
Someone views any panel page while lockedYes, with developer toolsAdd the X-Livewire header to a page request
Attack from the internet without access to the computerNoThe attacker must already be in the victim's logged-in browser (or hold the session cookie, which is a bigger problem on its own)
Gaining more rights than the user hasNoThe attacker acts as the locked-out user, with that user's permissions
Idle auto-lock protects an open pagePartlyIdle locking only happens on the next full page load; until then an open page stays usable (a design limit, mentioned separately)
Who is really hurt: the organisation whose admin panel it is - the plugin is installed precisely
so that a walked-away-from, logged-in admin screen cannot be used, and it does not deliver that.
The operating-system screen lock remains the stronger protection.

Proposed fix and how to verify it without reading code

The Locker middleware now checks the lock for Livewire requests too: when the session is locked, a Livewire request is answered with the redirect to the lock screen instead of being run. The lock screen itself still works, because its own page is not behind this middleware. To verify: lock the screen, press Back, click something on the old page -> you land on the lock screen. Unlock -> everything works again. Automated tests added with the fix check exactly these cases. </details> <details><summary>Patch (git format-patch of the signed commit 9386d72, applies to 3.x e2db5ad / v3.4.1)</summary>

From 9386d72ec1dde1d1e2a1762f2a5e5bc4353c7b77 Mon Sep 17 00:00:00 2001
From: Iefken <ieffalot@hotmail.com>
Date: Tue, 10 Nov 2026 10:20:37 +0100
Subject: [PATCH] fix(middleware): enforce the lockscreen on Livewire requests
Locker passed every request that carries the X-Livewire header straight
to the next middleware, before looking at the lockscreen session flag.
Livewire component updates reach Locker through Livewire's persistent
middleware, so while the screen was locked every action on a page that
was still open (another tab, the back button) kept working, and any page
request could skip the lock by adding that header.
Livewire requests now get the same lock check as page visits: when the
session is locked they are redirected to the lockscreen (Livewire aborts
the update with that redirect). They still do not count as activity for
the idle timeout.
Tests: add a Pest + Testbench suite (tests/) with a fixture panel page.
It checks that a locked session redirects a normal visit, a visit with
the X-Livewire header and a Livewire action of an already open page, and
that actions still run while unlocked.
Signed-off-by: Iefken <ieffalot@hotmail.com>
---
 src/Http/Middleware/Locker.php       | 20 ++++++-
 tests/Fixtures/SecretPage.php        | 33 ++++++++++++
 tests/Fixtures/TestPanelProvider.php | 41 ++++++++++++++
 tests/Fixtures/User.php              | 19 +++++++
 tests/LockerTest.php                 | 80 ++++++++++++++++++++++++++++
 tests/Pest.php                       |  5 ++
 tests/TestCase.php                   | 58 ++++++++++++++++++++
 7 files changed, 255 insertions(+), 1 deletion(-)
 create mode 100644 tests/Fixtures/SecretPage.php
 create mode 100644 tests/Fixtures/TestPanelProvider.php
 create mode 100644 tests/Fixtures/User.php
 create mode 100644 tests/LockerTest.php
 create mode 100644 tests/Pest.php
 create mode 100644 tests/TestCase.php
diff --git a/src/Http/Middleware/Locker.php b/src/Http/Middleware/Locker.php
index e74748b..0354073 100644
--- a/src/Http/Middleware/Locker.php
+++ b/src/Http/Middleware/Locker.php
@@ -19,14 +19,27 @@ class Locker
      */
     public function handle(Request $request, Closure $next): mixed
     {
+        /**
+         *  Livewire requests (component updates) run this middleware through Livewire's persistent
+         *  middleware, with the method and path of the page the component lives on. They must respect
+         *  the lock too: otherwise every action on a page that is still open (another tab, the back
+         *  button) keeps working while the screen is locked, and any request can skip the lock just
+         *  by sending the "X-Livewire" header. They are not counted as user activity.
+         */
         if ($this->isLivewireRequest()) {
+            if ($this->isLocked($request) && $this->isSegmentMatched($request)) {
+                $panelId = filament()->getCurrentPanel()?->getId();
+
+                return to_route("lockscreen.{$panelId}.page");
+            }
+
             return $next($request);
         }
 
         /**
          *  Normal Lock session
          */
-        if ($request->isMethod('GET') && ($request->session()->get('lockscreen') &&  (boolean) $request->session()->get('lockscreen') === true) && $this->isSegmentMatched($request)) {
+        if ($request->isMethod('GET') && $this->isLocked($request) && $this->isSegmentMatched($request)) {
             $panelId = filament()->getCurrentPanel()?->getId();
 
             redirect()->setIntendedUrl(url()->previous() ?? filament()->getDefaultPanel()->getPath());
@@ -57,6 +70,11 @@ class Locker
         return $next($request);
     }
 
+    protected function isLocked(Request $request): bool
+    {
+        return (bool) $request->session()->get('lockscreen') === true;
+    }
+
     protected function isSegmentMatched($request): bool
     {
         $panelPath = filament()->getCurrentPanel()?->getPath();
diff --git a/tests/Fixtures/SecretPage.php b/tests/Fixtures/SecretPage.php
new file mode 100644
index 0000000..982d6f9
--- /dev/null
+++ b/tests/Fixtures/SecretPage.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace lockscreen\FilamentLockscreen\Tests\Fixtures;
+
+use Filament\Pages\Page;
+use Filament\Schemas\Components\Text;
+use Filament\Schemas\Schema;
+
+/**
+ * A panel page with one Livewire action, standing in for any page of a real panel.
+ */
+class SecretPage extends Page
+{
+    protected static ?string $slug = 'secret';
+
+    /** Lets the tests find this component's snapshot in the rendered page. */
+    public bool $isSecretPage = true;
+
+    /** How many times reveal() ran in this process. */
+    public static int $revealed = 0;
+
+    public function reveal(): void
+    {
+        static::$revealed++;
+    }
+
+    public function content(Schema $schema): Schema
+    {
+        return $schema->components([
+            Text::make('secret page content'),
+        ]);
+    }
+}
diff --git a/tests/Fixtures/TestPanelProvider.php b/tests/Fixtures/TestPanelProvider.php
new file mode 100644
index 0000000..12f7051
--- /dev/null
+++ b/tests/Fixtures/TestPanelProvider.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace lockscreen\FilamentLockscreen\Tests\Fixtures;
+
+use Filament\Http\Middleware\Authenticate;
+use Filament\Http\Middleware\AuthenticateSession;
+use Filament\Http\Middleware\DisableBladeIconComponents;
+use Filament\Http\Middleware\DispatchServingFilamentEvent;
+use Filament\Panel;
+use Filament\PanelProvider;
+use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
+use Illuminate\Cookie\Middleware\EncryptCookies;
+use Illuminate\Routing\Middleware\SubstituteBindings;
+use Illuminate\Session\Middleware\StartSession;
+use Illuminate\View\Middleware\ShareErrorsFromSession;
+use lockscreen\FilamentLockscreen\Lockscreen;
+
+class TestPanelProvider extends PanelProvider
+{
+    public function panel(Panel $panel): Panel
+    {
+        return $panel
+            ->default()
+            ->id('admin')
+            ->path('admin')
+            ->login()
+            ->pages([SecretPage::class])
+            ->plugins([Lockscreen::make()->enablePlugin()])
+            ->middleware([
+                EncryptCookies::class,
+                AddQueuedCookiesToResponse::class,
+                StartSession::class,
+                AuthenticateSession::class,
+                ShareErrorsFromSession::class,
+                SubstituteBindings::class,
+                DisableBladeIconComponents::class,
+                DispatchServingFilamentEvent::class,
+            ])
+            ->authMiddleware([Authenticate::class]);
+    }
+}
diff --git a/tests/Fixtures/User.php b/tests/Fixtures/User.php
new file mode 100644
index 0000000..c584e23
--- /dev/null
+++ b/tests/Fixtures/User.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace lockscreen\FilamentLockscreen\Tests\Fixtures;
+
+use Filament\Models\Contracts\FilamentUser;
+use Filament\Panel;
+use Illuminate\Foundation\Auth\User as Authenticatable;
+
+class User extends Authenticatable implements FilamentUser
+{
+    protected $table = 'users';
+
+    protected $guarded = [];
+
+    public function canAccessPanel(Panel $panel): bool
+    {
+        return true;
+    }
+}
diff --git a/tests/LockerTest.php b/tests/LockerTest.php
new file mode 100644
index 0000000..8ee1b26
--- /dev/null
+++ b/tests/LockerTest.php
@@ -0,0 +1,80 @@
+<?php
+
+use Livewire\Mechanisms\HandleRequests\HandleRequests;
+use lockscreen\FilamentLockscreen\Tests\Fixtures\SecretPage;
+use lockscreen\FilamentLockscreen\Tests\Fixtures\User;
+
+beforeEach(function (): void {
+    SecretPage::$revealed = 0;
+
+    $this->actingAs(User::query()->create([
+        'name' => 'Test User',
+        'email' => 'test@example.com',
+        'password' => bcrypt('password'),
+    ]));
+});
+
+/**
+ * Render the secret page and return its Livewire snapshot, as an open browser tab holds it.
+ */
+function secretPageSnapshot(): string
+{
+    $html = test()->get('/admin/secret')->assertOk()->getContent();
+
+    preg_match_all('/wire:snapshot="([^"]+)"/', $html, $matches);
+
+    foreach ($matches[1] as $snapshot) {
+        $snapshot = html_entity_decode($snapshot, ENT_QUOTES);
+
+        if (array_key_exists('isSecretPage', json_decode($snapshot, true)['data'] ?? [])) {
+            return $snapshot;
+        }
+    }
+
+    throw new RuntimeException('No snapshot of the secret page found.');
+}
+
+/**
+ * Call reveal() on the secret page through the Livewire update endpoint, like the browser does.
+ */
+function callReveal(string $snapshot)
+{
+    return test()
+        ->withHeaders(['X-Livewire' => '1'])
+        ->postJson(app(HandleRequests::class)->getUpdateUri(), [
+            'components' => [[
+                'snapshot' => $snapshot,
+                'updates' => new stdClass,
+                'calls' => [['method' => 'reveal', 'params' => []]],
+            ]],
+        ]);
+}
+
+it('redirects a page visit to the lockscreen while locked', function (): void {
+    $this->withSession(['lockscreen' => true])
+        ->get('/admin/secret')
+        ->assertRedirect(route('lockscreen.admin.page'));
+});
+
+it('does not let the X-Livewire header skip the lockscreen', function (): void {
+    $this->withSession(['lockscreen' => true])
+        ->withHeaders(['X-Livewire' => '1'])
+        ->get('/admin/secret')
+        ->assertRedirect(route('lockscreen.admin.page'));
+});
+
+it('runs Livewire actions while unlocked', function (): void {
+    callReveal(secretPageSnapshot())->assertOk();
+
+    expect(SecretPage::$revealed)->toBe(1);
+});
+
+it('blocks Livewire actions of an already open page while locked', function (): void {
+    $snapshot = secretPageSnapshot();
+
+    $this->withSession(['lockscreen' => true]);
+
+    callReveal($snapshot)->assertRedirect(route('lockscreen.admin.page'));
+
+    expect(SecretPage::$revealed)->toBe(0);
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..60c830a
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,5 @@
+<?php
+
+use lockscreen\FilamentLockscreen\Tests\TestCase;
+
+uses(TestCase::class)->in(__DIR__);
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..293cc5e
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace lockscreen\FilamentLockscreen\Tests;
+
+use BladeUI\Heroicons\BladeHeroiconsServiceProvider;
+use BladeUI\Icons\BladeIconsServiceProvider;
+use Filament\Actions\ActionsServiceProvider;
+use Filament\FilamentServiceProvider;
+use Filament\Forms\FormsServiceProvider;
+use Filament\Infolists\InfolistsServiceProvider;
+use Filament\Notifications\NotificationsServiceProvider;
+use Filament\Schemas\SchemasServiceProvider;
+use Filament\Support\SupportServiceProvider;
+use Filament\Tables\TablesServiceProvider;
+use Filament\Widgets\WidgetsServiceProvider;
+use Livewire\LivewireServiceProvider;
+use lockscreen\FilamentLockscreen\FilamentLockscreenServiceProvider;
+use lockscreen\FilamentLockscreen\Tests\Fixtures\TestPanelProvider;
+use lockscreen\FilamentLockscreen\Tests\Fixtures\User;
+use Orchestra\Testbench\TestCase as Orchestra;
+use RyanChandler\BladeCaptureDirective\BladeCaptureDirectiveServiceProvider;
+
+class TestCase extends Orchestra
+{
+    protected function getPackageProviders($app): array
+    {
+        return [
+            ActionsServiceProvider::class,
+            BladeCaptureDirectiveServiceProvider::class,
+            BladeHeroiconsServiceProvider::class,
+            BladeIconsServiceProvider::class,
+            FilamentServiceProvider::class,
+            FormsServiceProvider::class,
+            InfolistsServiceProvider::class,
+            LivewireServiceProvider::class,
+            NotificationsServiceProvider::class,
+            SchemasServiceProvider::class,
+            SupportServiceProvider::class,
+            TablesServiceProvider::class,
+            WidgetsServiceProvider::class,
+            FilamentLockscreenServiceProvider::class,
+            TestPanelProvider::class,
+        ];
+    }
+
+    protected function defineEnvironment($app): void
+    {
+        $app['config']->set('app.key', 'base64:'.base64_encode(str_repeat('k', 32)));
+        $app['config']->set('auth.providers.users.model', User::class);
+        $app['config']->set('database.default', 'testing');
+        $app['config']->set('session.driver', 'array');
+    }
+
+    protected function defineDatabaseMigrations(): void
+    {
+        $this->loadLaravelMigrations();
+    }
+}
-- 
2.45.1.windows.1

</details> Issue found and fixed by Claude, then reviewed and confirmed by Ief (GitHub: iefken).


Source: NVD

Related PHP vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

GHSA-7rj2-q7vr-2299MEDIUM6.1
  • PHP logoPHP
  • marjose123/filament-lockscreen
NoNoNov 12, 2026
GHSA-8f7v-xf7j-hc3qMEDIUM4.8
  • PHP logoPHP
  • payos/payos
NoYesNov 12, 2026
GHSA-v5hc-3j8r-9h37LOW3.7
  • PHP logoPHP
  • payos/payos
NoYesNov 12, 2026
GHSA-h4qh-3qph-xfm6LOW3.7
  • PHP logoPHP
  • payos/payos
NoYesNov 12, 2026
GHSA-fp4v-3mhm-pwjxLOW3.7
  • PHP logoPHP
  • payos/payos
NoYesNov 12, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Additional Wiz resources

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management