From 2dcfc9f11d2f4fdbbe10c1b6fe27f51fb2e25731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Popowicz?= Date: Fri, 25 Sep 2026 14:50:29 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20the=20anti-lockout=20allow?= =?UTF-8?q?-list=20when=20Nova=20is=20mounted=20at=20the=20root=20("/")=20?= =?UTF-8?q?path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trim(Nova::path(), '/') empties a root path, so "{$novaPath}/user-security" became "/user-security" — which never matches Request::is()'s slash-less path, blocking the 2FA enrolment subtree and locking admins out. Build the prefix with a trailing slash only when there is one. --- .../Middleware/EnsureTwoFactorEnabled.php | 6 +++- tests/Feature/MiddlewareTest.php | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Http/Middleware/EnsureTwoFactorEnabled.php b/src/Http/Middleware/EnsureTwoFactorEnabled.php index a375bd5..ce47df6 100644 --- a/src/Http/Middleware/EnsureTwoFactorEnabled.php +++ b/src/Http/Middleware/EnsureTwoFactorEnabled.php @@ -87,13 +87,17 @@ protected function shouldPassThrough(Request $request): bool return true; } + // trim() empties a root Nova path ("/"), so build the prefix with a + // trailing slash only when there is one — otherwise the patterns would + // gain a leading slash and never match Request::is()'s slash-less path. $novaPath = trim(Nova::path(), '/'); + $prefix = $novaPath === '' ? '' : "{$novaPath}/"; // Anti-lockout: never block Nova's own assets, logout, or the enrolment // page, or an admin could brick themselves / break the SPA. Not // configurable — a consumer editing except.* can never strip these. if ($request->routeIs('nova.asset.*', 'nova.logout') - || $request->is("{$novaPath}/user-security", "{$novaPath}/user-security/*") + || $request->is("{$prefix}user-security", "{$prefix}user-security/*") ) { return true; } diff --git a/tests/Feature/MiddlewareTest.php b/tests/Feature/MiddlewareTest.php index 1ca767e..8ece625 100644 --- a/tests/Feature/MiddlewareTest.php +++ b/tests/Feature/MiddlewareTest.php @@ -133,6 +133,35 @@ function loginAdmin(bool $enrolled = false): User get('/nova/user-security/confirm')->assertOk()->assertSee('confirm'); }); +it('allow-lists the user-security subtree when the Nova path is root ("/")', function (): void { + // A root-mounted Nova (config nova.path = "/") makes Nova::path() return "/"; + // the enrolment subtree must still be reachable so an un-enrolled admin can set 2FA up. + config(['nova.path' => '/']); + + Route::middleware(['web', EnsureTwoFactorEnabled::class])->group(function (): void { + Route::get('/user-security', fn (): string => 'security')->name('nova.pages.user-security'); + Route::get('/user-security/two-factor-authentication', fn (): string => 'enable'); + }); + + loginAdmin(); // un-enrolled + + get('/user-security', ['Accept' => 'text/html'])->assertOk()->assertSee('security'); + get('/user-security/two-factor-authentication', ['Accept' => 'text/html'])->assertOk()->assertSee('enable'); +}); + +it('still redirects an un-enrolled admin to user security when the Nova path is root ("/")', function (): void { + config(['nova.path' => '/']); + + Route::middleware(['web', EnsureTwoFactorEnabled::class])->group(function (): void { + Route::get('/dashboard', fn (): string => 'dash'); + Route::get('/user-security', fn (): string => 'security')->name('nova.pages.user-security'); + }); + + loginAdmin(); + + get('/dashboard', ['Accept' => 'text/html'])->assertRedirect(route('nova.pages.user-security')); +}); + it('never blocks the Fortify 2FA enrolment endpoints under user-security', function (string $path): void { Route::middleware(['web', EnsureTwoFactorEnabled::class])->get($path, fn (): string => 'ok'); loginAdmin(); // un-enrolled — must still reach every enrolment endpoint