From 3207d71244ee0ffaf3fdd796c8539f6995d0620c Mon Sep 17 00:00:00 2001 From: fs Date: Sat, 25 Apr 2026 23:35:03 +0200 Subject: [PATCH] harden(logout): enforce POST+CSRF and remove GET logout triggers --- pages/admin/index().php | 2 +- pages/auth/logout().php | 7 ++++ templates/default.phtml | 10 ++++- templates/partials/app-topbar.phtml | 6 ++- .../AuthLogoutCsrfContractTest.php | 39 +++++++++++++++++++ web/js/components/app-session-warning.js | 27 +++++++++++-- 6 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 tests/Architecture/AuthLogoutCsrfContractTest.php diff --git a/pages/admin/index().php b/pages/admin/index().php index 05d0c04..17c4db5 100644 --- a/pages/admin/index().php +++ b/pages/admin/index().php @@ -13,6 +13,6 @@ $first_name = $user['first_name'] ?? ''; Buffer::set('title', t('Admin')); $breadcrumbs = [ - ['label' => t('Login'), 'path' => 'logout'], + ['label' => t('Login'), 'path' => 'login'], ['label' => t('Home')], ]; diff --git a/pages/auth/logout().php b/pages/auth/logout().php index 9702230..3c55f51 100644 --- a/pages/auth/logout().php +++ b/pages/auth/logout().php @@ -1,4 +1,11 @@ logoutAndRedirect('login'); diff --git a/templates/default.phtml b/templates/default.phtml index 276c301..c3cca42 100644 --- a/templates/default.phtml +++ b/templates/default.phtml @@ -15,6 +15,7 @@ $sessionIdleMinutes = (int) (appSetting('session_idle_timeout_minutes') ?? 30); $sessionIdleSeconds = max($sessionIdleMinutes, 5) * 60; $sessionPingUrl = lurl('admin/session-ping/data'); $sessionLogoutUrl = lurl('logout'); +$sessionLogoutFormId = 'app-logout-form'; $telemetryEnabled = frontendTelemetryEnabled(); $telemetrySampleRate = frontendTelemetrySampleRate(); $telemetryAllowedEvents = implode(',', frontendTelemetryAllowedEvents()); @@ -224,7 +225,7 @@ $componentPageConfig = [ 'selector' => '[data-app-session-warning-dialog]', 'idleSeconds' => $sessionIdleSeconds, 'pingUrl' => $sessionPingUrl, - 'logoutUrl' => $sessionLogoutUrl, + 'logoutFormId' => $sessionLogoutFormId, 'csrfKey' => $csrfKey, 'csrfToken' => $csrfToken, ], @@ -296,7 +297,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents; data-session-idle-seconds="" data-session-ping-url="" - data-session-logout-url="" + data-session-logout-form-id="" class="no-js" style="" @@ -334,6 +335,11 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents; + + + diff --git a/templates/partials/app-topbar.phtml b/templates/partials/app-topbar.phtml index eb192a4..8793af4 100644 --- a/templates/partials/app-topbar.phtml +++ b/templates/partials/app-topbar.phtml @@ -30,6 +30,7 @@ if ($tenantLabel === '') { } $canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 1; $allowUserTheme = allowUserTheme(); +$logoutFormId = trim((string) ($sessionLogoutFormId ?? 'app-logout-form')); $layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : []; $layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; $moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : []; @@ -196,9 +197,10 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu -
  • +
  • +
  • diff --git a/tests/Architecture/AuthLogoutCsrfContractTest.php b/tests/Architecture/AuthLogoutCsrfContractTest.php new file mode 100644 index 0000000..6f3768b --- /dev/null +++ b/tests/Architecture/AuthLogoutCsrfContractTest.php @@ -0,0 +1,39 @@ +readProjectFile('pages/auth/logout().php'); + + $this->assertStringContainsString("actionRequirePost('login')", $content); + $this->assertStringContainsString("actionRequireCsrf('login', 'login', 'logout_csrf')", $content); + } + + public function testTopbarLogoutUsesPostFormSubmitInsteadOfGetLink(): void + { + $content = $this->readProjectFile('templates/partials/app-topbar.phtml'); + + $this->assertStringNotContainsString('href="logout"', $content); + $this->assertStringContainsString('type="submit"', $content); + $this->assertStringContainsString('form=""', $content); + } + + public function testLayoutAndSessionWarningUseLogoutFormContract(): void + { + $layoutContent = $this->readProjectFile('templates/default.phtml'); + $sessionWarningJs = $this->readProjectFile('web/js/components/app-session-warning.js'); + + $this->assertStringContainsString('\'logoutFormId\' => $sessionLogoutFormId', $layoutContent); + $this->assertStringContainsString('data-session-logout-form-id=""', $layoutContent); + $this->assertStringContainsString('