From 80786cc8ddb0fc96f565912c02e4c975f738557b Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 16 Sep 2026 04:43:05 -0400 Subject: [PATCH] 1.8.2: a stale session cookie (unreadable file) starts a fresh session instead of a 500 cPanel's shared session dir after an account is recreated: the file belongs to the old uid. PHP defines SID even on a failed session_start(), so the check must come first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- CHANGELOG.md | 13 +++++++++ library/Tiger/Application/Bootstrap.php | 27 +++++++++++++++++++ library/Tiger/Version.php | 2 +- .../Unit/Application/BootstrapHelpersTest.php | 26 ++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e07b5cec..e4f5395d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.8.2] — 2026-09-16 + +### Fixed + +- **A stale session cookie no longer 500s the site.** With PHP's files handler, a `PHPSESSID` the + browser still carries can name a file this process cannot read — cPanel's shared session directory + after an account is deleted and recreated (the file belongs to the old uid, and every returning + visitor to the domain hits it). `session_start()` failed, and because PHP defines `SID` even on a + failed start there is no retry through `Zend_Session`. The bootstrap now checks the presented id's + file first and starts under a fresh id when it exists but is unreadable + (`Tiger_Application_Bootstrap::dropUnreadableSessionId()`), logging the drop. Found by the second + outside install round (TIGER-138). + ## [1.8.1] — 2026-09-15 Findings from an AI-driven install test of the web installer on a shared cPanel host (TIGER-138). diff --git a/library/Tiger/Application/Bootstrap.php b/library/Tiger/Application/Bootstrap.php index bd56bce3..f8bc65cb 100644 --- a/library/Tiger/Application/Bootstrap.php +++ b/library/Tiger/Application/Bootstrap.php @@ -662,10 +662,37 @@ protected function _initSession() } if (!Zend_Session::isStarted()) { + if (!$useDb) { self::dropUnreadableSessionId(); } Zend_Session::start(); } } + /** + * With PHP's files handler, a session id the browser still carries can name a file this process + * cannot read — cPanel's shared session directory after an account is deleted and recreated (the + * file belongs to the old uid; every returning visitor hits it), a save_path that changed hands, a + * corrupt file. session_start() then fails, and because PHP defines SID even on failure there is no + * retry through Zend_Session. So look first: if the presented id's file exists and is unreadable, + * start under a fresh id — the visitor is a guest either way, and a guest must never see a 500. + * + * @param string|null $sid the presented id (default: the session cookie) + * @param string|null $path session.save_path (default: the ini value) + * @return bool true when the presented id was dropped + */ + public static function dropUnreadableSessionId($sid = null, $path = null) + { + $sid = (string) ($sid ?? ($_COOKIE[session_name()] ?? '')); + if ($sid === '' || !preg_match('/^[A-Za-z0-9,-]{1,128}$/', $sid)) { return false; } + $path = (string) ($path ?? ini_get('session.save_path')); + if ($path === '') { $path = sys_get_temp_dir(); } + if (strpos($path, ';') !== false) { $path = substr($path, strrpos($path, ';') + 1); } // "N;/path" and "N;mode;/path" forms + $file = rtrim($path, '/') . '/sess_' . $sid; + if (!is_file($file) || is_readable($file)) { return false; } + error_log('Tiger session: the presented session file is not readable (' . $file . '); starting a new session'); + if (session_status() !== PHP_SESSION_ACTIVE) { session_id(bin2hex(random_bytes(16))); } + return true; + } + /** True when this request carries a Tiger personal access token (stateless mode). */ protected function _bearerRequest() { diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 970d8cc4..6b0e24d0 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.8.1'; + const VERSION = '1.8.2'; } diff --git a/tests/Unit/Application/BootstrapHelpersTest.php b/tests/Unit/Application/BootstrapHelpersTest.php index 48c8366c..250d9a59 100644 --- a/tests/Unit/Application/BootstrapHelpersTest.php +++ b/tests/Unit/Application/BootstrapHelpersTest.php @@ -124,4 +124,30 @@ public function language_files_return_the_existing_cascade_members(): void } $this->assertContains(TIGER_CORE_PATH . '/core/languages/en/core.php', $files, 'the core language file leads the cascade'); } + + /** + * A presented session id whose file cannot be read (cPanel's shared session dir after an account is + * recreated) must be dropped BEFORE session_start — PHP defines SID even on a failed start, so there + * is no retry afterwards. A readable file, a missing file, or a malformed id are left alone. + */ + #[Test] + public function an_unreadable_presented_session_file_is_dropped_before_start(): void + { + $dir = sys_get_temp_dir() . '/tiger-sess-' . bin2hex(random_bytes(4)); mkdir($dir, 0700); + $log = ini_set('error_log', $dir . '/error.log'); // the guard logs what it dropped; keep that off the test output + try { + $bad = str_repeat('b', 26); file_put_contents("$dir/sess_$bad", 'x'); chmod("$dir/sess_$bad", 0000); + $this->assertTrue(Tiger_Application_Bootstrap::dropUnreadableSessionId($bad, '5;' . $dir), 'unreadable → dropped ("N;/path" form parsed)'); + $this->assertTrue(Tiger_Application_Bootstrap::dropUnreadableSessionId($bad, '5;0600;' . $dir), '"N;mode;/path" form parsed'); + $good = str_repeat('c', 26); file_put_contents("$dir/sess_$good", 'x'); + $this->assertFalse(Tiger_Application_Bootstrap::dropUnreadableSessionId($good, $dir), 'readable → kept'); + $this->assertFalse(Tiger_Application_Bootstrap::dropUnreadableSessionId(str_repeat('d', 26), $dir), 'no such file → kept (PHP creates it)'); + $this->assertFalse(Tiger_Application_Bootstrap::dropUnreadableSessionId('../../etc/passwd', $dir), 'junk never reaches the filesystem'); + $this->assertFalse(Tiger_Application_Bootstrap::dropUnreadableSessionId('', $dir)); + $this->assertStringContainsString('not readable', (string) file_get_contents($dir . '/error.log'), 'the drop is logged'); + } finally { + ini_set('error_log', (string) $log); + @chmod("$dir/sess_$bad", 0600); array_map('unlink', glob("$dir/sess_*") ?: []); @unlink($dir . '/error.log'); @rmdir($dir); + } + } }