From 5cacfa42d0c2d0dbe2cb71314cce2dd2660c9f78 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 30 Aug 2026 19:01:10 +0200 Subject: [PATCH 1/2] fix(core): open redirect, insufficient randomness, missing audit if composer.lock unchanged. --- .github/workflows/ci.yml | 30 +++++++++ core/functions/helper.php | 40 ++++++++---- core/src/Core.php | 64 ++++++++++++++++--- .../Unit/Security/PasswordGeneratorTest.php | 58 +++++++++++++++++ .../Unit/Security/RedirectTargetTest.php | 60 +++++++++++++++++ 5 files changed, 232 insertions(+), 20 deletions(-) create mode 100644 core/tests/Unit/Security/PasswordGeneratorTest.php create mode 100644 core/tests/Unit/Security/RedirectTargetTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5677867ce6..2d73f7e0af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,3 +91,33 @@ jobs: - name: Run Pest working-directory: core run: composer test -- --compact + + audit: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + # The audit reads composer.lock and the advisory database; it does not run the code, + # so one version is enough. Kept at the lowest supported one for consistency. + php-version: '8.3' + extensions: ${{ env.PHP_EXTENSIONS }} + coverage: none + tools: composer:v2 + + # --locked audits the lock file directly: no vendor tree has to be installed, and what is + # audited is exactly what a site gets. core/ is where the shipped dependencies live. + - name: Audit shipped dependencies + working-directory: core + run: composer audit --locked --no-dev + + # Dev-only tooling (Pest and friends) never reaches a live site, so an advisory there is + # worth seeing but must not block a release. The root composer.json holds PHPStan alone and + # ships no lock file, so there is nothing to audit there. + - name: Audit development dependencies + continue-on-error: true + working-directory: core + run: composer audit --locked diff --git a/core/functions/helper.php b/core/functions/helper.php index 457bbaeed5..7f05d8fd78 100644 --- a/core/functions/helper.php +++ b/core/functions/helper.php @@ -29,16 +29,26 @@ function revision(string $path = ''): string if (!function_exists('createGUID')) { /** - * create globally unique identifiers (guid) - * - * @return string + * @throws \Random\RandomException */ - function createGUID() + function createGUID(int $version = 4, $dashes = false): string { - mt_srand((float)microtime() * 1000000); - $r = mt_rand(); - $u = uniqid(getmypid() . $r . (float)microtime() * 1000000, 1); - return md5($u); + $data = random_bytes(16); + if ($version === 7) { + $milliseconds = (int)floor(microtime(true) * 1000); + for ($i = 5; $i >= 0; $i--) { + $data[$i] = chr($milliseconds & 0xff); + $milliseconds = intdiv($milliseconds, 256); + } + $data[6] = chr((ord($data[6]) & 0x0f) | 0x70); + } else { + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + } + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + $hex = bin2hex($data); + return !$dashes ? $hex : substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . + substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); } } @@ -46,17 +56,23 @@ function createGUID() /** * Generate password * + * Used for the password proposed when an account is created, which is then mailed to its + * owner - so it has to be unguessable. mt_rand() seeded from microtime() was not: the seed + * is the creation time, which anyone receiving such a mail can narrow down to a second. + * * @param int $length * @return string + * @throws \Random\RandomException */ - function generate_password($length = 10) + function generate_password(int $length = 10): string { + // No look-alike characters (l/1/I, O/0), because these passwords get read off a screen and typed by hand. $allowable_characters = 'abcdefghjkmnpqrstuvxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $ps_len = strlen($allowable_characters); - mt_srand((float)microtime() * 1000000); - $pass = ""; + $length = max(1, $length); + $pass = ''; for ($i = 0; $i < $length; $i++) { - $pass .= $allowable_characters[mt_rand(0, $ps_len - 1)]; + $pass .= $allowable_characters[random_int(0, $ps_len - 1)]; } return $pass; diff --git a/core/src/Core.php b/core/src/Core.php index d937b554ac..6dcf926480 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -494,14 +494,10 @@ public function sendRedirect(string $url, int $count_attempts = 0, string $type } // Only allow redirects to the same domain or relative paths to prevent open redirect vulnerability - $parsed_url = parse_url($url); - if (isset($parsed_url['scheme'])) { - if (!in_array(strtolower($parsed_url['scheme']), ['http', 'https']) || $parsed_url['host'] !== - parse_url(EVO_SITE_URL)['host']) { - $this->getService('ExceptionHandler')->messageQuit( - 'External or invalid redirect not allowed: ' . htmlspecialchars($url) . '' - ); - } + if (!$this->isLocalRedirectTarget($url, EVO_SITE_URL)) { + $this->getService('ExceptionHandler')->messageQuit( + 'External or invalid redirect not allowed: ' . htmlspecialchars($url) . '' + ); } // Fix: Prevent header injection by checking for newlines in all redirect types @@ -557,6 +553,58 @@ public function sendRedirect(string $url, int $count_attempts = 0, string $type exit(0); } + /** + * Decide whether a redirect target stays on this site. + * + * Relative paths pass, absolute URLs pass only when the host is our own. Everything else + * is refused - including the two shapes that carry no scheme and therefore used to skip + * the check entirely: "//evil.tld" is protocol-relative, and browsers normalise the + * backslash variants ("/\evil.tld", "\/evil.tld") to the same thing before following + * the Location header. + * + * @param string $url the target as it would be sent to the browser + * @param string $siteUrl EVO_SITE_URL, or any absolute URL naming this site + * @return bool + * @since 3.5.8 + */ + public function isLocalRedirectTarget(string $url, string $siteUrl): bool + { + // The URL parser used by browsers strips ASCII whitespace/control characters before + // resolving a target (and strips tabs and newlines within it). parse_url() does not, so + // accepting them would let a value such as " //evil.tld" masquerade as a relative path. + if (preg_match('/[\x00-\x20\x7f]/', $url) === 1) { + return false; + } + + // Browsers read a backslash in the authority position as a slash, so the check has to + // read it that way too before deciding whether an authority is present at all. + $normalized = str_replace('\\', '/', $url); + + $parsed = parse_url($normalized); + if ($parsed === false) { + return false; + } + + $hasAuthority = isset($parsed['host']) || str_starts_with($normalized, '//'); + + if (!isset($parsed['scheme']) && !$hasAuthority) { + return true; // a relative path, which can only stay on this site + } + + if (isset($parsed['scheme']) && !in_array(strtolower($parsed['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = isset($parsed['host']) ? (string) $parsed['host'] : ''; + if ($host === '') { + return false; // "//" with nothing behind it, or an authority we could not read + } + + $siteHost = parse_url($siteUrl, PHP_URL_HOST); + + return is_string($siteHost) && $siteHost !== '' && strcasecmp($host, $siteHost) === 0; + } + /** * Forwards request processing to another document id within the current request. * diff --git a/core/tests/Unit/Security/PasswordGeneratorTest.php b/core/tests/Unit/Security/PasswordGeneratorTest.php new file mode 100644 index 0000000000..228a427863 --- /dev/null +++ b/core/tests/Unit/Security/PasswordGeneratorTest.php @@ -0,0 +1,58 @@ +toBe($length) + ->and($password)->toMatch('/^[abcdefghjkmnpqrstuvxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$/'); + } +}); + +test('it does not repeat itself within a single second', function () { + // The clock-seeded version returned the same password for every call that landed in the + // same microsecond, which is what a loop like this reproduces. + $passwords = []; + for ($i = 0; $i < 200; $i++) { + $passwords[] = generate_password(10); + } + + expect(count(array_unique($passwords)))->toBe(200); +}); + +test('it spreads across the alphabet rather than a seeded sequence', function () { + $sample = ''; + for ($i = 0; $i < 200; $i++) { + $sample .= generate_password(10); + } + + // 2000 characters over a 54-character alphabet: a generator stuck on a narrow seed shows up + // here as a handful of distinct characters. + expect(count(array_unique(str_split($sample))))->toBeGreaterThan(40); +}); + +test('guids are unique across calls in the same request', function () { + $guids = []; + for ($i = 0; $i < 200; $i++) { + $guids[] = createGUID(); + } + + expect(count(array_unique($guids)))->toBe(200) + ->and($guids[0])->toMatch('/^[0-9a-f]{32}$/'); +}); diff --git a/core/tests/Unit/Security/RedirectTargetTest.php b/core/tests/Unit/Security/RedirectTargetTest.php new file mode 100644 index 0000000000..ce6543708e --- /dev/null +++ b/core/tests/Unit/Security/RedirectTargetTest.php @@ -0,0 +1,60 @@ +newInstanceWithoutConstructor(); +} + +test('same-site and relative targets are allowed', function (string $url) { + expect(redirectGuard()->isLocalRedirectTarget($url, 'https://example.com/'))->toBeTrue(); +})->with([ + 'relative path' => ['index.php?id=12'], + 'root-relative path' => ['/news/article/'], + 'root-relative path with a query' => ['/index.php?id=12&err=1'], + 'absolute url on this host' => ['https://example.com/manager/'], + 'absolute url on this host over http' => ['http://example.com/manager/'], + 'host casing differs' => ['https://EXAMPLE.com/manager/'], +]); + +test('cross-site targets are refused', function (string $url) { + expect(redirectGuard()->isLocalRedirectTarget($url, 'https://example.com/'))->toBeFalse(); +})->with([ + 'absolute url on another host' => ['https://evil.tld/'], + // The regression: no scheme, so the old check never ran. + 'protocol-relative' => ['//evil.tld/'], + 'protocol-relative without a trailing slash' => ['//evil.tld'], + 'backslash after the slash' => ['/\\evil.tld/'], + 'backslash before the slash' => ['\\/evil.tld/'], + 'both backslashes' => ['\\\\evil.tld/'], + 'userinfo pointing at another host' => ['https://example.com@evil.tld/'], + 'subdomain of a lookalike' => ['https://example.com.evil.tld/'], + 'non-http scheme' => ['javascript:alert(1)'], + 'data url' => ['data:text/html,'], + 'empty authority' => ['///'], + 'leading space before an authority' => [' //evil.tld/'], + 'leading tab before an authority' => ["\t//evil.tld/"], + 'leading tab before an absolute url' => ["\thttps://evil.tld/"], +]); + +test('a site url without a host never matches', function () { + expect(redirectGuard()->isLocalRedirectTarget('https://example.com/', ''))->toBeFalse(); +}); From 5adbb56fbe6cb2de55a536cd230f6785c57d01bd Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 30 Aug 2026 19:02:08 +0200 Subject: [PATCH 2/2] fix(installer): insufficient IP check on installer dir removal, missing tests. --- .gitignore | 3 + core/src/Support/InstallerCompletion.php | 78 +++++++++++++ core/tests/Feature/InstallerWebPathTest.php | 107 ++++++++++++++++++ core/tests/Feature/SiteUpdateE2ETest.php | 92 +++++++++++++++ .../Unit/Security/InstallerCompletionTest.php | 58 ++++++++++ install/src/controllers/install.php | 15 ++- install/src/template/actions/install.php | 22 ++-- .../processors/remove_installer.processor.php | 61 ++++++++-- 8 files changed, 409 insertions(+), 27 deletions(-) create mode 100644 core/src/Support/InstallerCompletion.php create mode 100644 core/tests/Feature/InstallerWebPathTest.php create mode 100644 core/tests/Unit/Security/InstallerCompletionTest.php diff --git a/.gitignore b/.gitignore index 5c330741a0..23f570f593 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ /manager/includes/config.inc.php /manager/media/style/default/css/styles.min.css +# Installer lock +/install.session.php + # Composer /composer.phar diff --git a/core/src/Support/InstallerCompletion.php b/core/src/Support/InstallerCompletion.php new file mode 100644 index 0000000000..edbe94dc75 --- /dev/null +++ b/core/src/Support/InstallerCompletion.php @@ -0,0 +1,78 @@ + $install_ip, + 'timestamp' => (int) $install_timestamp, + 'token' => $install_token, + ]; + } + + /** + * @param array{ip: string, timestamp: int, token: string}|null $lock + */ + public static function matches( + ?array $lock, + string $token, + string $ip, + int $now, + int $maxLifetime + ): bool { + if ($lock === null || $token === '') { + return false; + } + + if ($maxLifetime <= 0) { + $maxLifetime = 1440; + } + + return $lock['timestamp'] > 0 + && $now <= $lock['timestamp'] + $maxLifetime + && $ip === $lock['ip'] + && hash_equals($lock['token'], $token); + } +} diff --git a/core/tests/Feature/InstallerWebPathTest.php b/core/tests/Feature/InstallerWebPathTest.php new file mode 100644 index 0000000000..79ffeaf84c --- /dev/null +++ b/core/tests/Feature/InstallerWebPathTest.php @@ -0,0 +1,107 @@ + $root, + 'lock' => $root . '/install.session.php', + 'install' => $install, + ]; +} + +/** + * Run the real standalone processor with a temporary site root. + * + * @param array $post + * @param array $cookies + */ +function runInstallerRemoval(array $fixture, array $post, array $cookies = [], string $method = 'POST'): Process +{ + $processor = str_replace('\\', '/', dirname(__DIR__, 3)) + . '/manager/processors/remove_installer.processor.php'; + $runner = $fixture['root'] . '/request.php'; + $source = "run(); + + return $process; +} + +function removeInstallerWebFixture(array $fixture): void +{ + (new Filesystem())->deleteDirectory($fixture['root']); +} + +test('fresh web install requires successful completion before its installer can be removed', function () { + $fixture = installerWebFixture(); + $token = str_repeat('a', 64); + + try { + file_put_contents( + $fixture['lock'], + " $token, + 'rminstaller' => '1', + ]); + + expect($beforeCompletion->isSuccessful())->toBeTrue() + ->and($beforeCompletion->getOutput())->toContain('Not found.') + ->and(is_dir($fixture['install']))->toBeTrue(); + + expect(InstallerCompletion::writeLock( + $fixture['lock'], + 'anonymous-session', + '203.0.113.10', + time(), + $token + ))->toBeTrue(); + + $getAttempt = runInstallerRemoval($fixture, [ + 'installer_token' => $token, + 'rminstaller' => '1', + ], [], 'GET'); + + expect($getAttempt->isSuccessful())->toBeTrue() + ->and($getAttempt->getOutput())->toContain('Not found.') + ->and(is_dir($fixture['install']))->toBeTrue(); + + $afterCompletion = runInstallerRemoval($fixture, [ + 'installer_token' => $token, + 'rminstaller' => '1', + ]); + + expect($afterCompletion->isSuccessful())->toBeTrue() + ->and($afterCompletion->getOutput())->toContain("window.location='../#?a=2'") + ->and(is_dir($fixture['install']))->toBeFalse() + ->and(is_file($fixture['lock']))->toBeFalse(); + } finally { + removeInstallerWebFixture($fixture); + } +}); diff --git a/core/tests/Feature/SiteUpdateE2ETest.php b/core/tests/Feature/SiteUpdateE2ETest.php index c5d737ab30..af97b91cdf 100644 --- a/core/tests/Feature/SiteUpdateE2ETest.php +++ b/core/tests/Feature/SiteUpdateE2ETest.php @@ -22,7 +22,13 @@ use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Eloquent\Model; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Facade; +use EvolutionCMS\Models\SystemCliTask; +use EvolutionCMS\Services\SystemTasks\SchedulerHealthService; +use EvolutionCMS\Services\SystemTasks\SiteUpdateFlowService; +use EvolutionCMS\Services\SystemTasks\SystemTaskService; +use EvolutionCMS\Services\SystemTasks\WorkerHealthService; if (!defined('EVO_BASE_PATH')) { define('EVO_BASE_PATH', str_replace('\\', '/', dirname(__DIR__, 3)) . '/'); @@ -202,6 +208,38 @@ public function applyExtrasModule(): void $command->applyExtrasModule(); } +/** + * Build an isolated core tree whose Artisan entry point records the requested target as the + * installed version. SiteUpdateFlowService still launches it through the real process boundary. + */ +function managerUpgradeCoreFixture(string $currentVersion): string +{ + $core = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-manager-upgrade-' . bin2hex(random_bytes(8)) . '/core'; + mkdir($core . '/factory', 0777, true); + file_put_contents( + $core . '/factory/version.php', + " $currentVersion], true) . ";\n" + ); + file_put_contents($core . '/artisan', <<<'PHP' + $targetVersion], true) . ";\n" +); +echo "Evolution CMS $targetVersion updated\n"; +PHP + ); + + return $core . '/'; +} + test('update from version N to N+1 applies migrations, update seeders and refreshes Extras', function () { $capsule = bootSiteUpdateDatabase(); seedVersionNDatabase($capsule); @@ -240,6 +278,60 @@ public function applyExtrasModule(): void ->and($extras->modulecode)->not->toBe('OUTDATED MODULE CODE'); }); +test('manager system upgrade action queues work and finishes on the requested system version', function () { + $capsule = bootSiteUpdateDatabase(); + seedVersionNDatabase($capsule); + (new \CreateSystemCliTasksTables())->up(); + (new SchedulerHealthService())->recordHeartbeat('feature-test', 'manual'); + $workerHealth = new WorkerHealthService(); + $workerHealth->markRun('feature-worker', 1234); + $workerHealth->markSuccess('feature-worker', 1234); + + $currentVersion = '3.5.8'; + $targetVersion = '3.5.9'; + $corePath = managerUpgradeCoreFixture($currentVersion); + $taskService = new SystemTaskService(); + + try { + $versionBeforeUpdate = include $corePath . 'factory/version.php'; + expect($versionBeforeUpdate['version'])->toBe($currentVersion); + + // This is the service call made by updaterHandleSystemTaskRequest() when an authenticated + // super administrator presses the manager's system-update button. + $queued = $taskService->createTaskFromStoreRequest('site_update', [ + 'target_ref' => $targetVersion, + 'backup_database' => '0', + ], [ + 'user_id' => 1, + 'permissions' => [ + 'exec_module' => 1, + 'system_tasks.view' => 1, + 'system_tasks.site_update' => 1, + ], + 'session_hash' => hash('sha256', 'authenticated-manager-cookie'), + ], true); + + expect($queued['ok'])->toBeTrue() + ->and($queued['task']['status'])->toBe('queued') + ->and($queued['task']['requested_version'])->toBe($targetVersion); + + $task = $taskService->acquireNextQueuedTask('feature-worker', 'feature-host', 1234); + expect($task)->toBeInstanceOf(SystemCliTask::class); + + $result = (new SiteUpdateFlowService($corePath))->execute($task); + $taskService->markTaskSucceeded($task, $result['message'], $result['result']); + + $installedVersion = include $corePath . 'factory/version.php'; + $completedTask = $task->fresh(); + + expect($completedTask->status)->toBe('succeeded') + ->and($completedTask->result_json['target_ref'])->toBe($targetVersion) + ->and($installedVersion['version'])->toBe($targetVersion); + } finally { + (new Filesystem())->deleteDirectory(dirname(rtrim($corePath, '/'))); + } +}); + test('moveFiles replaces files into the destination tree', function () { $base = sys_get_temp_dir() . '/evo_update_' . uniqid(); $src = $base . '/src'; diff --git a/core/tests/Unit/Security/InstallerCompletionTest.php b/core/tests/Unit/Security/InstallerCompletionTest.php new file mode 100644 index 0000000000..b4cdc7855e --- /dev/null +++ b/core/tests/Unit/Security/InstallerCompletionTest.php @@ -0,0 +1,58 @@ + '203.0.113.10', + 'timestamp' => $now - 10, + 'token' => $token, + ]; + + expect(InstallerCompletion::matches($lock, $token, '203.0.113.10', $now, 1440))->toBeTrue() + ->and(InstallerCompletion::matches($lock, str_repeat('b', 64), '203.0.113.10', $now, 1440))->toBeFalse() + ->and(InstallerCompletion::matches($lock, $token, '203.0.113.11', $now, 1440))->toBeFalse() + ->and(InstallerCompletion::matches($lock, $token, '203.0.113.10', $now + 1441, 1440))->toBeFalse(); +}); + +test('a generic installer lock is not a removal capability', function () { + $lockFile = tempnam(sys_get_temp_dir(), 'evo-install-lock-'); + file_put_contents( + $lockFile, + "toBeNull(); + } finally { + unlink($lockFile); + } +}); + +test('a completed installer lock exposes only removal authorization fields', function () { + $lockFile = tempnam(sys_get_temp_dir(), 'evo-install-lock-'); + $token = str_repeat('c', 64); + file_put_contents( + $lockFile, + "toBe([ + 'ip' => '::1', + 'timestamp' => 1_800_000_000, + 'token' => $token, + ]); + } finally { + unlink($lockFile); + } +}); diff --git a/install/src/controllers/install.php b/install/src/controllers/install.php index e261952465..3042b1a891 100644 --- a/install/src/controllers/install.php +++ b/install/src/controllers/install.php @@ -1,6 +1,7 @@ -
+ + @@ -351,23 +355,11 @@

- diff --git a/manager/processors/remove_installer.processor.php b/manager/processors/remove_installer.processor.php index e9fde65e93..b407662269 100755 --- a/manager/processors/remove_installer.processor.php +++ b/manager/processors/remove_installer.processor.php @@ -1,5 +1,7 @@ alert('" . addslashes($msg) . "');"; } echo ""; - -