From ea876da0c02cc1d777cd63c4ca20f1dbb220b4c4 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 16 Jul 2026 13:10:15 -0400 Subject: [PATCH 1/2] Fix CSRF token fetch silently extending an expired CP session Fetching a fresh CSRF token for the session-expiry login form touched the session's last_activity, resetting the idle timer and making an expired session look active again. Excludes the token route from extending the session, the same way the session-timeout polling route already was. Fixes #12484 --- src/Http/Middleware/CP/StartSession.php | 7 ++- tests/Http/Middleware/StartSessionTest.php | 58 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/Http/Middleware/StartSessionTest.php diff --git a/src/Http/Middleware/CP/StartSession.php b/src/Http/Middleware/CP/StartSession.php index ce0378b6ce1..7069a91c889 100644 --- a/src/Http/Middleware/CP/StartSession.php +++ b/src/Http/Middleware/CP/StartSession.php @@ -6,10 +6,15 @@ class StartSession extends Middleware { + protected $routesExcludedFromExtendingSession = [ + 'statamic.cp.session.timeout', + 'statamic.cp.token', + ]; + protected function saveSession($request) { if ( - $request->route()->named('statamic.cp.session.timeout') + $request->route()->named($this->routesExcludedFromExtendingSession) && $request->session()->has('last_activity') ) { return; diff --git a/tests/Http/Middleware/StartSessionTest.php b/tests/Http/Middleware/StartSessionTest.php new file mode 100644 index 00000000000..ff5454931f6 --- /dev/null +++ b/tests/Http/Middleware/StartSessionTest.php @@ -0,0 +1,58 @@ +freezeTime(); + $user = tap(User::make()->makeSuper())->save(); + + $this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk(); + $this->assertEquals(now()->timestamp, session('last_activity')); + + $this->travel(30)->seconds(); + + $this->actingAs($user)->get(cp_route('session.timeout'))->assertOk(); + $this->assertNotEquals(now()->timestamp, session('last_activity')); + } + + #[Test] + public function hitting_the_token_route_does_not_extend_the_session() + { + $this->freezeTime(); + $user = tap(User::make()->makeSuper())->save(); + + $this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk(); + $this->assertEquals(now()->timestamp, session('last_activity')); + + $this->travel(30)->seconds(); + + $this->actingAs($user)->get(cp_route('token'))->assertOk(); + $this->assertNotEquals(now()->timestamp, session('last_activity')); + } + + #[Test] + public function hitting_a_normal_cp_route_extends_the_session() + { + $this->freezeTime(); + $user = tap(User::make()->makeSuper())->save(); + + $this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk(); + $this->assertEquals(now()->timestamp, session('last_activity')); + + $this->travel(30)->seconds(); + + $this->actingAs($user)->get(cp_route('elevated-session.status'))->assertOk(); + $this->assertEquals(now()->timestamp, session('last_activity')); + } +} From a30ea183c690227a9b2ded1acc957014ea2eef92 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 16 Jul 2026 13:10:24 -0400 Subject: [PATCH 2/2] Prevent accidental dismissal of session-expiry modals The warning and resume-session modals could be closed by clicking the overlay or pressing Esc (the resume-session and two-factor modals had a typo, :dismissable instead of :dismissible, so it was never actually applied), leaving no clear way to get back to them. Both modals now require an explicit Cancel action to close. Cancelling either one shows a banner explaining the session is expiring/expired, which reopens the relevant modal when clicked. --- lang/en/messages.php | 2 + resources/js/components/SessionExpiry.vue | 91 +++++++++++++++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/lang/en/messages.php b/lang/en/messages.php index b3b3062cb34..6089db9099a 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -234,6 +234,8 @@ 'selections_item_unselected' => ':title is not selected. Click to select.', 'selections_limit_reached' => 'Selection limit reached. Cannot select :title', 'selections_select_all' => ':selected of :total items selected. Check to select all items.', + 'session_expiry_dismissed_banner' => 'Your session is about to expire. Click here to extend it and stay signed in.', + 'session_expiry_dismissed_login_banner' => 'Your session has expired. Click here to log back in.', 'session_expiry_enter_password' => 'Enter your password to continue.', 'session_expiry_enter_two_factor_code' => 'Enter your authenticator code to continue.', 'session_expiry_enter_two_factor_recovery_code' => 'Enter a recovery code to continue.', diff --git a/resources/js/components/SessionExpiry.vue b/resources/js/components/SessionExpiry.vue index 5a8dd86c05b..553487a50dd 100644 --- a/resources/js/components/SessionExpiry.vue +++ b/resources/js/components/SessionExpiry.vue @@ -1,17 +1,33 @@