From 94e60f311d7843ff9a3a6e07c1062f0e21ced3a8 Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Mon, 2 Nov 2026 19:52:09 +0200 Subject: [PATCH 01/50] fix(file-browser): preserve group permissions for shared storage --- core/src/Support/FileBrowserAccess.php | 139 +++++++++++ core/tests/Fixtures/shared-browser-access.php | 223 ++++++++++++++++++ .../Unit/Security/SharedBrowserAccessTest.php | 115 +++++++++ manager/includes/config_check.inc.php | 15 +- manager/media/browser/mcpuk/core/browser.php | 65 ++++- 5 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 core/tests/Fixtures/shared-browser-access.php create mode 100644 core/tests/Unit/Security/SharedBrowserAccessTest.php diff --git a/core/src/Support/FileBrowserAccess.php b/core/src/Support/FileBrowserAccess.php index 9e236de085..2953fd3852 100644 --- a/core/src/Support/FileBrowserAccess.php +++ b/core/src/Support/FileBrowserAccess.php @@ -80,4 +80,143 @@ public static function resolveUploadRoot( return null; } + + /** + * Find the site-side spelling of a configured upload URL, without trusting it as a grant. + * Callers must still compare its real directory with the configured physical upload root. + * Remote URLs, traversal and encoded paths cannot establish an ACL identity. + * + * @return string|null Absolute site-side path, which may end at a deployment symlink. + * @since 3.6.0 + */ + public static function localUploadPath(string $url, string $basePath, string $baseUrl, string $siteUrl): ?string + { + $parts = parse_url($url); + $site = parse_url($siteUrl); + if ($parts === false || $site === false || isset($parts['query']) || isset($parts['fragment']) + || isset($parts['user']) || isset($parts['pass']) + || (isset($parts['scheme']) && !in_array(strtolower($parts['scheme']), ['http', 'https'], true)) + || (isset($parts['host']) && (strcasecmp($parts['host'], $site['host'] ?? '') !== 0 + || ($parts['port'] ?? null) !== ($site['port'] ?? null)))) { + return null; + } + + $path = $parts['path'] ?? ''; + if (strpbrk($path, "\\%\0:") !== false || in_array('..', explode('/', $path), true) + || \fileManagerRefuseReservedName($path)) { + return null; + } + $baseUrl = '/' . trim($baseUrl, '/'); + if (str_starts_with($path, '/')) { + if ($baseUrl !== '/' && !FileManagerAccess::isWithin($baseUrl, $path)) { + return null; + } + $path = $baseUrl === '/' ? ltrim($path, '/') : ltrim(substr($path, strlen($baseUrl)), '/'); + } + + $root = realpath($basePath); + if ($root === false) { + return null; + } + $segments = array_filter(explode('/', $path), static fn ($part) => $part !== '' && $part !== '.'); + + return rtrim(str_replace('\\', '/', $root), '/') . '/' . implode('/', $segments); + } + + /** + * Keep file_groups keys stable when a deployment exposes a configured shared type root. + * Only the type root may be an alias: the browser must separately reject every symlink + * below it. The site-side root is accepted only when realpath proves it is the same folder. + * No matching root returns null; callers must not interpret that as a public file. + * + * @return string|null Key relative to the site-wide ACL root; an empty key is that root itself. + * @since 3.6.0 + */ + public static function resolveAclPath(string $aclRoot, string $typeDir, string $absolutePath, ?string $localTypeDir = null): ?string + { + foreach ([$aclRoot, $typeDir, $absolutePath, $localTypeDir ?? ''] as $path) { + if (strpos($path, "\0") !== false || \fileManagerRefuseReservedName($path)) { + return null; + } + } + $root = realpath($aclRoot); + $typeRoot = realpath($typeDir); + if ($root === false || $typeRoot === false) { + return null; + } + $root = rtrim(str_replace('\\', '/', $root), '/'); + $typeRoot = rtrim(str_replace('\\', '/', $typeRoot), '/'); + $typeDir = rtrim(str_replace('\\', '/', $typeDir), '/'); + $absolutePath = rtrim(str_replace('\\', '/', $absolutePath), '/'); + $spelling = FileManagerAccess::isWithin($typeDir, $absolutePath) ? $typeDir : $typeRoot; + if (!FileManagerAccess::isWithin($spelling, $absolutePath)) { + return null; + } + $suffix = ltrim(substr($absolutePath, strlen($spelling)), '/'); + if (in_array('..', explode('/', $suffix), true) || strpos($suffix, "\0") !== false + || \fileManagerPathContainsLink($typeDir, $absolutePath)) { + return null; + } + $existing = realpath($absolutePath); + if ($existing !== false) { + $existing = str_replace('\\', '/', $existing); + if (!FileManagerAccess::isWithin($typeRoot, $existing)) { + return null; + } + $suffix = ltrim(substr($existing, strlen($typeRoot)), '/'); + } else { + $suffix = implode('/', array_filter(explode('/', $suffix), static fn ($part) => $part !== '' && $part !== '.')); + } + + if (FileManagerAccess::isWithin($root, $typeRoot)) { + $key = ltrim(substr($typeRoot, strlen($root)), '/'); + } elseif ($localTypeDir !== null && FileManagerAccess::isWithin($root, $localTypeDir) + && ($localRoot = realpath($localTypeDir)) !== false + && FileManagerAccess::isWithin($typeRoot, str_replace('\\', '/', $localRoot)) + && FileManagerAccess::isWithin(str_replace('\\', '/', $localRoot), $typeRoot)) { + $key = ltrim(substr($localTypeDir, strlen($root)), '/'); + } else { + return null; + } + + return trim($key . '/' . $suffix, '/'); + } + + /** + * Diagnose an upload root without opening it to an unrecognised ACL namespace. + * External shared roots need a proven site-side identity for every existing browser type. + * This read-only check never creates folders or replaces the browser's operation guards. + * + * @since 3.6.0 + */ + public static function isUsableRoot(string $uploadDir, string $uploadUrl, string $aclRoot, string $basePath, string $baseUrl, string $siteUrl): bool + { + $uploadDir = str_replace('[(base_path)]', $basePath, $uploadDir); + $aclRoot = str_replace('[(base_path)]', $basePath, $aclRoot); + if (strpos($uploadDir . $aclRoot, "\0") !== false + || \fileManagerRefuseReservedName($uploadDir) || \fileManagerRefuseReservedName($aclRoot)) { + return false; + } + if (!is_dir($uploadDir) || !is_readable($uploadDir) || !is_dir($aclRoot) || !is_readable($aclRoot)) { + return false; + } + if (self::resolveAclPath($aclRoot, $uploadDir, $uploadDir) !== null) { + return true; + } + + $found = false; + foreach (['files', 'images', 'file', 'image', 'media'] as $type) { + $typeDir = rtrim($uploadDir, '/\\') . '/' . $type; + if (!is_dir($typeDir)) { + continue; + } + $found = true; + $local = self::localUploadPath(rtrim($uploadUrl, '/') . '/' . $type, $basePath, $baseUrl, $siteUrl); + if (self::resolveAclPath($aclRoot, $typeDir, $typeDir, $local) === null) { + return false; + } + } + + return $found; + } } diff --git a/core/tests/Fixtures/shared-browser-access.php b/core/tests/Fixtures/shared-browser-access.php new file mode 100644 index 0000000000..64cff8b429 --- /dev/null +++ b/core/tests/Fixtures/shared-browser-access.php @@ -0,0 +1,223 @@ +config[$key] ?? ['filemanager_path' => EVO_BASE_PATH, 'use_udperms' => 1][$key] ?? $default; + } + + /** + * Keep the dashboard checksum check outside this filesystem regression fixture. + * + * @since 3.6.0 + */ + public function getManagerApi(): self + { + return $this; + } + + /** + * The isolated site has no tracked CMS checksum changes. + * + * @since 3.6.0 + */ + public function checkSystemChecksum(): string + { + return '0'; + } + + /** + * Capture refusals for assertions without writing a CMS event log. + * + * @since 3.6.0 + */ + public function logEvent($id, $type, $message, $source): void + { + $this->log[] = $message; + } +} + +$GLOBALS['evo'] = new SharedBrowserStub(); +/** + * Supply the fixture CMS host to the unmodified browser helpers. + * + * @since 3.6.0 + */ +function evo() { return $GLOBALS['evo']; } +/** + * Supply the legacy spelling used by the refusal logger. + * + * @since 3.6.0 + */ +function evolutionCMS() { return evo(); } + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; +$browserDir = dirname(__DIR__, 3) . '/manager/media/browser/mcpuk/'; +require $browserDir . 'lib/helper_path.php'; +require $browserDir . 'lib/helper_dir.php'; +require $browserDir . 'core/uploader.php'; +require $browserDir . 'core/browser.php'; + +try { + if (!@symlink($tmp . '/shared/assets/images', EVO_BASE_PATH . 'assets/images') + || !@symlink($tmp . '/shared/assets/files', EVO_BASE_PATH . 'assets/files')) { + echo json_encode(['skip' => 'symlinks are not available']); + return; + } + file_put_contents($tmp . '/shared/assets/images/private/secret.txt', 'secret'); + file_put_contents($tmp . '/shared/assets/images/public/open.txt', 'open'); + symlink($tmp . '/outside', $tmp . '/shared/assets/images/escape'); + symlink($tmp . '/shared/assets/images/private', $tmp . '/shared/assets/images/internal'); + symlink($tmp . '/outside/missing', $tmp . '/shared/assets/images/dangling'); + mkdir($tmp . '/shared/assets/.thumbs/images', 0777, true); + symlink($tmp . '/outside', $tmp . '/shared/assets/.thumbs-link'); + + $capsule = new Illuminate\Database\Capsule\Manager(); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getConnection()->statement('CREATE TABLE file_groups (id INTEGER PRIMARY KEY, document_group INTEGER, file TEXT)'); + $capsule->getConnection()->table('file_groups')->insert([ + 'id' => 1, 'document_group' => 7, 'file' => 'assets/images/private', + ]); + + $browser = (new ReflectionClass(browser::class))->newInstanceWithoutConstructor(); + $typeDir = $tmp . '/shared/assets/images'; + foreach (['modx' => evo(), 'typeDir' => $typeDir, 'typeURL' => 'https://example.test/assets/images', + 'type' => 'images', 'config' => ['uploadDir' => $tmp . '/shared/assets']] as $name => $value) { + (new ReflectionProperty(uploader::class, $name))->setValue($browser, $value); + } + (new ReflectionProperty(browser::class, 'protectedPaths'))->setValue($browser, []); + $invoke = static fn ($name, ...$args) => (new ReflectionMethod(browser::class, $name))->invoke($browser, ...$args); + $_SESSION = ['mgrRole' => 2, 'mgrDocgroups' => []]; + $secret = $typeDir . '/private/secret.txt'; + $open = $typeDir . '/public/open.txt'; + $output = [ + 'key' => $invoke('getFileGroupsRelPath', $secret), + 'newKey' => $invoke('getFileGroupsRelPath', $typeDir . '/private/new.txt'), + 'dottedKey' => $invoke('getFileGroupsRelPath', $typeDir . '/./private/secret.txt'), + 'deniedRead' => $invoke('isPathAccessible', $secret), + 'deniedWrite' => $invoke('isWriteAllowed', 'private'), + 'publicRead' => $invoke('isPathAccessible', $open), + 'publicWrite' => $invoke('isWriteAllowed', 'public'), + 'listing' => array_map('basename', $invoke('filterAccessiblePaths', [$secret, $open])), + 'subtreeSecret' => $invoke('subtreeAccessFilter', $typeDir)($secret), + 'protectedDescendants' => $invoke('hasInaccessibleDescendants', $typeDir), + 'escape' => $invoke('isPathAccessible', $typeDir . '/escape'), + 'internal' => $invoke('isPathAccessible', $typeDir . '/internal/secret.txt'), + 'visibleLink' => $invoke('isLinkVisible', $typeDir . '/escape'), + 'dangling' => $invoke('isWriteAllowed', 'dangling'), + 'traversal' => $invoke('isPathAccessible', $typeDir . '/../files'), + 'safeThumbs' => !fileManagerPathContainsLink($tmp . '/shared/assets', $tmp . '/shared/assets/.thumbs/images'), + 'linkedThumbs' => !fileManagerPathContainsLink($tmp . '/shared/assets', $tmp . '/shared/assets/.thumbs-link/images'), + 'diagnostic' => EvolutionCMS\Support\FileBrowserAccess::isUsableRoot( + $tmp . '/shared/assets', 'assets/', EVO_BASE_PATH, EVO_BASE_PATH, '/', EVO_SITE_URL + ), + 'missingDiagnostic' => EvolutionCMS\Support\FileBrowserAccess::isUsableRoot( + $tmp . '/missing', 'assets/', EVO_BASE_PATH, EVO_BASE_PATH, '/', EVO_SITE_URL + ), + 'unmappedDiagnostic' => EvolutionCMS\Support\FileBrowserAccess::isUsableRoot( + $tmp . '/shared/assets', 'unmapped/', EVO_BASE_PATH, EVO_BASE_PATH, '/', EVO_SITE_URL + ), + 'standardDiagnostic' => EvolutionCMS\Support\FileBrowserAccess::isUsableRoot( + '[(base_path)]assets/', 'assets/', '[(base_path)]', EVO_BASE_PATH, '/', EVO_SITE_URL + ), + 'standardKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( + EVO_BASE_PATH, EVO_BASE_PATH . 'assets/local', EVO_BASE_PATH . 'assets/local/new.txt' + ), + 'wrongTargetKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( + EVO_BASE_PATH, $typeDir, $open, EVO_BASE_PATH . 'assets/local' + ), + 'unconfinedKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( + EVO_BASE_PATH, $typeDir, $open, $typeDir + ), + 'personalKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( + EVO_BASE_PATH, $typeDir . '/private', $secret, EVO_BASE_PATH . 'assets/images/private' + ), + ]; + $_SESSION['mgrDocgroups'] = [7]; + $output['memberRead'] = $invoke('isPathAccessible', $secret); + $output['memberWrite'] = $invoke('isWriteAllowed', 'private'); + $output['memberSubtree'] = $invoke('subtreeAccessFilter', $typeDir)($secret); + $output['memberDescendants'] = $invoke('hasInaccessibleDescendants', $typeDir); + + (new ReflectionProperty(uploader::class, 'typeURL'))->setValue($browser, 'https://example.test/unmapped/images'); + $output['unmappedKey'] = $invoke('getFileGroupsRelPath', $open); + $output['unmappedRead'] = $invoke('isPathAccessible', $open); + $output['unmappedWrite'] = $invoke('isWriteAllowed', 'public'); + $output['unmappedListing'] = $invoke('filterAccessiblePaths', [$open]); + $output['unmappedSubtree'] = $invoke('subtreeAccessFilter', $typeDir)($open); + $output['unmappedDescendants'] = $invoke('hasInaccessibleDescendants', $typeDir); + $output['unmappedLink'] = $invoke('isLinkVisible', $typeDir . '/escape'); + $output['log'] = evo()->log; + $_SESSION['mgrRole'] = 1; + $output['adminRead'] = $invoke('isPathAccessible', $open); + $output['adminEscape'] = $invoke('isPathAccessible', $typeDir . '/escape'); + // Another release must retain the same stored key, without moving file_groups rows. + mkdir($tmp . '/next-release/assets', 0777, true); + symlink($typeDir, $tmp . '/next-release/assets/images'); + $output['nextReleaseKey'] = EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( + $tmp . '/next-release', $typeDir, $secret, $tmp . '/next-release/assets/images' + ); + + // Execute the actual dashboard include, including its independent file-manager check. + $capsule->getConnection()->statement('CREATE TABLE site_content (id INTEGER, published INTEGER, privateweb INTEGER, deleted INTEGER)'); + require dirname(__DIR__, 2) . '/lang/en/global.php'; + foreach ([ + 'shared' => [$tmp . '/shared/assets/', 'assets/', '[(base_path)]'], + 'portable' => ['[(base_path)]assets/', 'assets/', '[(base_path)]'], + 'missing' => [$tmp . '/missing/', 'assets/', '[(base_path)]'], + 'unmapped' => [$tmp . '/shared/assets/', 'unmapped/', '[(base_path)]'], + 'missingManager' => ['[(base_path)]assets/', 'assets/', '[(base_path)]missing/'], + 'outsideManager' => [$tmp . '/shared/assets/', 'assets/', $tmp . '/shared/'], + 'traversalManager' => ['[(base_path)]assets/', 'assets/', '[(base_path)]../shared/'], + ] as $case => [$upload, $url, $managerRoot]) { + evo()->config = ['rb_base_dir' => $upload, 'rb_base_url' => $url, 'filemanager_path' => $managerRoot]; + include dirname(__DIR__, 3) . '/manager/includes/config_check.inc.php'; + $messages = array_column($warnings, 0); + $output['dashboard'][$case] = [ + 'browserWarning' => in_array($_lang['configcheck_rb_base_dir'], $messages, true), + 'managerWarning' => in_array($_lang['configcheck_filemanager_path'], $messages, true), + ]; + } + echo json_encode($output); +} finally { + $items = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($items as $item) { + if ($item->isLink() || !$item->isDir()) { + unlink($item->getPathname()); + } else { + rmdir($item->getPathname()); + } + } + rmdir($tmp); +} diff --git a/core/tests/Unit/Security/SharedBrowserAccessTest.php b/core/tests/Unit/Security/SharedBrowserAccessTest.php new file mode 100644 index 0000000000..7019869bc1 --- /dev/null +++ b/core/tests/Unit/Security/SharedBrowserAccessTest.php @@ -0,0 +1,115 @@ +toBe(0, $output); + expect($result)->toBeArray($output); + } + if (isset($result['skip'])) { + test()->markTestSkipped($result['skip']); + } + + return $result; +} + +it('preserves old group keys for shared roots and newly created files', function () { + $result = sharedBrowserResult(); + expect($result['key'])->toBe('assets/images/private/secret.txt') + ->and($result['dottedKey'])->toBe($result['key']) + ->and($result['nextReleaseKey'])->toBe($result['key']) + ->and($result['personalKey'])->toBe($result['key']) + ->and($result['standardKey'])->toBe('assets/local/new.txt') + ->and($result['newKey'])->toBe('assets/images/private/new.txt'); +}); + +it('enforces groups in listings direct access writes and recursive reads', function () { + $result = sharedBrowserResult(); + expect($result['deniedRead'])->toBeFalse() + ->and($result['deniedWrite'])->toBeFalse() + ->and($result['publicRead'])->toBeTrue() + ->and($result['publicWrite'])->toBeTrue() + ->and($result['listing'])->toBe(['open.txt']) + ->and($result['subtreeSecret'])->toBeFalse() + ->and($result['protectedDescendants'])->toBeTrue() + ->and($result['memberRead'])->toBeTrue() + ->and($result['memberWrite'])->toBeTrue() + ->and($result['memberSubtree'])->toBeTrue() + ->and($result['memberDescendants'])->toBeFalse(); +}); + +it('refuses and logs an unmapped ACL root instead of treating it as public', function () { + $result = sharedBrowserResult(); + expect($result['unmappedKey'])->toBeNull() + ->and($result['unmappedRead'])->toBeFalse() + ->and($result['unmappedWrite'])->toBeFalse() + ->and($result['unmappedListing'])->toBe([]) + ->and($result['unmappedSubtree'])->toBeFalse() + ->and($result['unmappedDescendants'])->toBeTrue() + ->and($result['unmappedLink'])->toBeFalse() + ->and(implode('\n', $result['log']))->toContain('file groups root cannot be resolved'); +}); + +it('keeps symlinks and traversal refused including for administrators', function () { + $result = sharedBrowserResult(); + foreach (['escape', 'internal', 'dangling', 'traversal', 'adminEscape'] as $key) { + expect($result[$key])->toBeFalse($key); + } + expect($result['adminRead'])->toBeTrue(); + expect($result['visibleLink'])->toBeTrue(); // inert link entry; opening it is still refused + expect($result['safeThumbs'])->toBeTrue() + ->and($result['linkedThumbs'])->toBeFalse(); +}); + +it('accepts a proven shared root but reports missing and unmapped roots', function () { + $result = sharedBrowserResult(); + expect($result['diagnostic'])->toBeTrue() + ->and($result['missingDiagnostic'])->toBeFalse() + ->and($result['unmappedDiagnostic'])->toBeFalse(); + expect($result['standardDiagnostic'])->toBeTrue() + ->and($result['wrongTargetKey'])->toBeNull() + ->and($result['unconfinedKey'])->toBeNull(); +}); + +it('does not derive local roots from unsafe or remote URLs', function (string $url) { + expect(FileBrowserAccess::localUploadPath($url, sys_get_temp_dir(), '/cms/', 'https://example.test/cms/'))->toBeNull(); +})->with(['https://other.test/cms/assets/images', '/other/assets/images', '../assets/images', + '/cms/assets/../images', '/cms/assets/%2e%2e/images', 'assets/images?x=1', 'assets/images#x', + 'https://user@example.test/cms/assets/images', 'file:///cms/assets/images', 'assets/CON/images']); + +it('refuses device names and NUL bytes before probing configured paths', function () { + expect(FileBrowserAccess::resolveAclPath('/NUL', '/tmp', '/tmp/test'))->toBeNull() + ->and(FileBrowserAccess::resolveAclPath('/tmp', '/tmp', "/tmp/bad\0name"))->toBeNull() + ->and(FileBrowserAccess::isUsableRoot('/CON', 'assets/', '/tmp', '/tmp', '/', 'https://example.test/'))->toBeFalse(); +}); + +it('renders dashboard path warnings independently and only accepts proven browser roots', function () { + $dashboard = sharedBrowserResult()['dashboard']; + foreach (['shared', 'portable'] as $case) { + expect($dashboard[$case])->toBe(['browserWarning' => false, 'managerWarning' => false]); + } + foreach (['missing', 'unmapped'] as $case) { + expect($dashboard[$case])->toBe(['browserWarning' => true, 'managerWarning' => false]); + } + expect($dashboard['missingManager']['managerWarning'])->toBeTrue() + ->and($dashboard['traversalManager']['managerWarning'])->toBeTrue() + ->and($dashboard['outsideManager']['managerWarning'])->toBeTrue(); +}); + +it('resolves site relative URLs for a subdirectory installation', function () { + $root = rtrim(str_replace('\\', '/', realpath(sys_get_temp_dir())), '/'); + foreach (['assets/images', '/cms/assets/images', 'https://example.test/cms/assets/images'] as $url) { + expect(FileBrowserAccess::localUploadPath($url, sys_get_temp_dir(), '/cms/', 'https://example.test/cms/')) + ->toBe($root . '/assets/images'); + } +}); diff --git a/manager/includes/config_check.inc.php b/manager/includes/config_check.inc.php index 2f4422f842..61d23f2dff 100755 --- a/manager/includes/config_check.inc.php +++ b/manager/includes/config_check.inc.php @@ -130,11 +130,22 @@ function checkSiteCache() { $warnings[] = [$_lang['configcheck_images']]; } -if(strpos(EvolutionCMS()->config['rb_base_dir'], EVO_BASE_PATH) !==0 && strpos(EvolutionCMS()->config['rb_base_dir'], '[(base_path)]') !==0) { +if (!\EvolutionCMS\Support\FileBrowserAccess::isUsableRoot( + (string) EvolutionCMS()->config['rb_base_dir'], + (string) EvolutionCMS()->config['rb_base_url'], + (string) EvolutionCMS()->config['filemanager_path'], + EVO_BASE_PATH, + EVO_BASE_URL, + EVO_SITE_URL +)) { $warnings[] = [$_lang['configcheck_rb_base_dir']]; } -if(strpos(EvolutionCMS()->config['filemanager_path'], EVO_BASE_PATH) !==0 && strpos(EvolutionCMS()->config['rb_base_dir'], '[(base_path)]assets/') !==0) { +$filemanagerRoot = str_replace('\\', '/', str_replace('[(base_path)]', EVO_BASE_PATH, (string) EvolutionCMS()->config['filemanager_path'])); +if (strpos($filemanagerRoot, "\0") !== false || fileManagerRefuseReservedName($filemanagerRoot) + || !is_dir($filemanagerRoot) || !is_readable($filemanagerRoot) + || in_array('..', explode('/', $filemanagerRoot), true) + || !\EvolutionCMS\Support\FileManagerAccess::isWithin(EVO_BASE_PATH, $filemanagerRoot)) { $warnings[] = [$_lang['configcheck_filemanager_path']]; } diff --git a/manager/media/browser/mcpuk/core/browser.php b/manager/media/browser/mcpuk/core/browser.php index 348f568119..c447495694 100755 --- a/manager/media/browser/mcpuk/core/browser.php +++ b/manager/media/browser/mcpuk/core/browser.php @@ -12,6 +12,7 @@ * @link http://kcfinder.sunhater.com */ +/** Embedded manager browser; path containment and file-group checks apply to every action. */ class browser extends uploader { /** Name prefix of the temporary download archives; the cleanup removes nothing else. */ @@ -1598,6 +1599,10 @@ protected function isLinkVisible($absPath) return true; } $parentKey = $this->getFileGroupsRelPath($parent); + if ($parentKey === null) { + $this->logDenied('file groups root cannot be resolved', $parent); + return false; + } $key = ltrim($parentKey . '/' . basename($absPath), '/'); $userGroups = array_map('intval', (array)($_SESSION['mgrDocgroups'] ?? [])); @@ -1960,17 +1965,28 @@ protected function output($data = null, $template = null) /** * Convert an absolute filesystem path to a path relative to filemanager_path, - * as stored in the file_groups table. Returns '' if the path is outside the root. + * as stored in file_groups. A shared type root keeps its proven site-side key; + * an unrecognised root returns null so group-protected operations fail closed. * * @param string $absPath - * @return string + * @return string|null */ - protected function getFileGroupsRelPath(string $absPath): string + protected function getFileGroupsRelPath(string $absPath): ?string { // the site-wide root, not the manager's own filemanager_path: groups are keyed by it - return \EvolutionCMS\Support\FileManagerAccess::getRelativePath( - \EvolutionCMS\Support\FileManagerAccess::aclRoot(), - $absPath + $aclRoot = \EvolutionCMS\Support\FileManagerAccess::aclRoot(); + $direct = \EvolutionCMS\Support\FileBrowserAccess::resolveAclPath($aclRoot, $this->typeDir, $absPath); + if ($direct !== null) { + return $direct; + } + + return \EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( + $aclRoot, + $this->typeDir, + $absPath, + \EvolutionCMS\Support\FileBrowserAccess::localUploadPath( + (string) $this->typeURL, EVO_BASE_PATH, EVO_BASE_URL, EVO_SITE_URL + ) ); } @@ -1997,15 +2013,23 @@ protected function filterAccessiblePaths(array $absPaths): array $userGroups = array_map('intval', (array)($_SESSION['mgrDocgroups'] ?? [])); $itemRelativePaths = []; foreach ($absPaths as $ap) { - $itemRelativePaths[$ap] = $this->getFileGroupsRelPath($ap); + $key = $this->getFileGroupsRelPath($ap); + if ($key === null) { + $this->logDenied('file groups root cannot be resolved', $ap); + continue; + } + $itemRelativePaths[$ap] = $key; } $restricted = \EvolutionCMS\Support\FileManagerAccess::loadRestrictions(array_values($itemRelativePaths)); $result = []; foreach ($absPaths as $ap) { - $relativePath = $itemRelativePaths[$ap] ?? ''; + if (!array_key_exists($ap, $itemRelativePaths)) { + continue; + } + $relativePath = $itemRelativePaths[$ap]; if ($relativePath === '') { - $result[] = $ap; // rel path unknown, let through + $result[] = $ap; // the ACL root itself has no per-entry row continue; } if (\EvolutionCMS\Support\FileManagerAccess::isAccessible($relativePath, $userGroups, $restricted)) { @@ -2040,6 +2064,10 @@ protected function isWriteAllowed($relDir) } $relPath = $this->getFileGroupsRelPath($absPath); + if ($relPath === null) { + $this->logDenied('file groups root cannot be resolved', $absPath); + return false; + } if ($relPath === '') { return true; } @@ -2070,6 +2098,10 @@ protected function isPathAccessible($absPath) } $relPath = $this->getFileGroupsRelPath($absPath); + if ($relPath === null) { + $this->logDenied('file groups root cannot be resolved', $absPath); + return false; + } if ($relPath === '') { return true; } @@ -2095,7 +2127,12 @@ protected function subtreeAccessFilter($absDir) return fn ($path) => $this->isInsideTypeDir($path); } - $restrictions = \EvolutionCMS\Support\FileManagerAccess::loadSubtreeRestrictions($this->getFileGroupsRelPath($absDir)); + $rootKey = $this->getFileGroupsRelPath($absDir); + if ($rootKey === null) { + $this->logDenied('file groups root cannot be resolved', $absDir); + return static fn ($path) => false; + } + $restrictions = \EvolutionCMS\Support\FileManagerAccess::loadSubtreeRestrictions($rootKey); $userGroups = array_map('intval', (array)($_SESSION['mgrDocgroups'] ?? [])); return function ($path) use ($restrictions, $userGroups) { @@ -2103,6 +2140,10 @@ protected function subtreeAccessFilter($absDir) return false; } $relPath = $this->getFileGroupsRelPath($path); + if ($relPath === null) { + $this->logDenied('file groups root cannot be resolved', $path); + return false; + } return $relPath === '' || \EvolutionCMS\Support\FileManagerAccess::isAccessible($relPath, $userGroups, $restrictions); @@ -2168,6 +2209,10 @@ protected function hasInaccessibleDescendants($absDir) } $relPath = $this->getFileGroupsRelPath($absDir); + if ($relPath === null) { + $this->logDenied('file groups root cannot be resolved', $absDir); + return true; + } if ($relPath === '') { return false; } From 0bd022d0216d3fa324ea920733b2fb86dc39fe59 Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Mon, 2 Nov 2026 20:04:12 +0200 Subject: [PATCH 02/50] fix(tests): accept cached site-wide ACL root lookup --- core/tests/Unit/Manager/FileManagerAclSourceTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/tests/Unit/Manager/FileManagerAclSourceTest.php b/core/tests/Unit/Manager/FileManagerAclSourceTest.php index 9752b267e9..50ba1ce5c5 100644 --- a/core/tests/Unit/Manager/FileManagerAclSourceTest.php +++ b/core/tests/Unit/Manager/FileManagerAclSourceTest.php @@ -4,6 +4,7 @@ use Tests\TestCase; +/** Source guards for ACL entry points and the ownership of persisted file-group rows. */ final class FileManagerAclSourceTest extends TestCase { public function testClassicManagerSourceEnforcesAclOnGroupSaveAndView(): void @@ -73,6 +74,7 @@ public function testKcfinderChecksFileGroupsOnEveryReadAndChangeOfANamedEntry(): } } + /** Assert both managers use the site-wide ACL root and carry rows across entry changes. */ public function testFileGroupsAreKeyedBySiteRootAndFollowEveryRenameAndDelete(): void { $classic = (string) file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php'); @@ -84,7 +86,8 @@ public function testFileGroupsAreKeyedBySiteRootAndFollowEveryRenameAndDelete(): self::assertStringContainsString("\$groupsKey = \$groupsTarget === null ? null : fileManagerAclKey(\$groupsTargetPath);", $classic); self::assertStringContainsString("FileGroup::query()->where('file', \$groupsKey)->get();", $classic); self::assertStringNotContainsString("where('file', \$groupsTargetPath)", $classic); - self::assertStringContainsString('\EvolutionCMS\Support\FileManagerAccess::aclRoot(),', $browser); + // The global root may be stored in a variable before the shared-path mapping. + self::assertStringContainsString('\EvolutionCMS\Support\FileManagerAccess::aclRoot()', $browser); self::assertStringNotContainsString("getConfig('filemanager_path', EVO_BASE_PATH)", $browser); // rows follow the entry whoever renames or deletes it, not only group managers From 1dc336ca29d77194ed6f1d299c48f47f2baf2642 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 3 Nov 2026 03:04:25 +0100 Subject: [PATCH 03/50] fix(file-browser): map shared roots from the configured upload URL The shared-root mapping read KCFinder's typeURL, which resolves a relative rb_base_url against the browser script (or gives up when DOCUMENT_ROOT is a symlink), so it never named the folder: restricted managers got an empty, read-only browser on shared storage. Use the configured URL instead. A type root the site serves from outside a narrowed filemanager_path carries no file groups again instead of refusing every action. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + core/src/Support/FileBrowserAccess.php | 16 +++++--- core/tests/Fixtures/shared-browser-access.php | 38 ++++++++++++++++--- .../Unit/Security/SharedBrowserAccessTest.php | 21 +++++++++- manager/media/browser/mcpuk/config.php | 3 ++ manager/media/browser/mcpuk/core/browser.php | 9 ++++- 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d4d78d6e21..4f0c0bf796 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -449,6 +449,7 @@ The name check also catches the spellings Windows turns into such a file: traili - Path comparison (`FileManagerAccess::isWithin()`) ignores case on Windows, and ZIP entries are resolved to the stored letter case (`fileManagerCanonicalCase()`) before the checks. - The media browser (KCFinder) follows the same rules: it stays out of the folders of layer 1 (`isInProtectedFolder()`), applies the executable-name rule to every name it writes (`validateFilename()`, including the final name of an upload), its temporary download archives carry the `kcf-download-` prefix and are the only `.zip` files it ever cleans up, and a folder rename never replaces an existing destination. +- The media browser keys file groups by the site-side spelling of its type root (`getFileGroupsRelPath()`, `FileBrowserAccess::resolveAclPath()`), so a type root moved to shared storage (`rb_base_dir` outside the release, `assets/images` a symlink to it) keeps its `assets/images/...` rows. That spelling comes from the configured `rb_base_url` (`_siteUploadURL`), never from KCFinder's `typeURL`, which it resolves against the browser script. The symlink must resolve exactly to the type root; an unproven spelling inside the ACL root refuses every group-checked action and logs it, while a type root the site serves from outside the ACL root (a narrowed `filemanager_path`) carries no groups, as anything else outside it. Test: `core/tests/Unit/Security/SharedBrowserAccessTest.php`. - ZIP entries with absolute paths, `:` or `..` segments are skipped. **Refusals are logged.** Every refusal above is written to the event log (a warning from source `File manager`, with the manager as its user), where administrators read it: `fileManagerLogDenied()` / `fileManagerDenied()` in the classic manager and its ZIP extraction, `logDenied()` in the media browser. Log the rule that refused, not routine misses (an extension that is merely not on the allowed list is not logged; an executable name is). Values from the request are escaped, one line per refusal, at most 20 per request. A new refusal that only returns a message is incomplete. diff --git a/core/src/Support/FileBrowserAccess.php b/core/src/Support/FileBrowserAccess.php index 2953fd3852..e2b96b4814 100644 --- a/core/src/Support/FileBrowserAccess.php +++ b/core/src/Support/FileBrowserAccess.php @@ -129,7 +129,8 @@ public static function localUploadPath(string $url, string $basePath, string $ba * below it. The site-side root is accepted only when realpath proves it is the same folder. * No matching root returns null; callers must not interpret that as a public file. * - * @return string|null Key relative to the site-wide ACL root; an empty key is that root itself. + * @return string|null Key relative to the site-wide ACL root; an empty key is that root itself, + * or a type root the site serves from outside it, where no group applies. * @since 3.6.0 */ public static function resolveAclPath(string $aclRoot, string $typeDir, string $absolutePath, ?string $localTypeDir = null): ?string @@ -170,13 +171,16 @@ public static function resolveAclPath(string $aclRoot, string $typeDir, string $ if (FileManagerAccess::isWithin($root, $typeRoot)) { $key = ltrim(substr($typeRoot, strlen($root)), '/'); - } elseif ($localTypeDir !== null && FileManagerAccess::isWithin($root, $localTypeDir) - && ($localRoot = realpath($localTypeDir)) !== false - && FileManagerAccess::isWithin($typeRoot, str_replace('\\', '/', $localRoot)) - && FileManagerAccess::isWithin(str_replace('\\', '/', $localRoot), $typeRoot)) { + } elseif ($localTypeDir === null || ($localRoot = realpath($localTypeDir)) === false + || !FileManagerAccess::isWithin($typeRoot, str_replace('\\', '/', $localRoot)) + || !FileManagerAccess::isWithin(str_replace('\\', '/', $localRoot), $typeRoot)) { + return null; + } elseif (FileManagerAccess::isWithin($root, $localTypeDir)) { $key = ltrim(substr($localTypeDir, strlen($root)), '/'); } else { - return null; + // the site serves this folder from outside the ACL root (a narrowed filemanager_path), + // where no file_groups row can name it (FileManagerAccess::aclKey()): nothing applies + return ''; } return trim($key . '/' . $suffix, '/'); diff --git a/core/tests/Fixtures/shared-browser-access.php b/core/tests/Fixtures/shared-browser-access.php index 64cff8b429..bd41c1bac2 100644 --- a/core/tests/Fixtures/shared-browser-access.php +++ b/core/tests/Fixtures/shared-browser-access.php @@ -2,7 +2,7 @@ /** Exercise the real browser guards with release symlinks, SQLite ACLs and an editor session. */ $tmp = sys_get_temp_dir() . '/evo-shared-browser-' . bin2hex(random_bytes(6)); -foreach (['release/assets/local', 'release/assets/cache', 'shared/assets/images/private', 'shared/assets/images/public', 'shared/assets/files', 'outside'] as $dir) { +foreach (['release/assets/local', 'release/assets/cache', 'release/assets/pictures', 'shared/assets/images/private', 'shared/assets/images/public', 'shared/assets/files', 'outside'] as $dir) { mkdir($tmp . '/' . $dir, 0777, true); } define('EVO_BASE_PATH', $tmp . '/release/'); @@ -112,8 +112,9 @@ function evolutionCMS() { return evo(); } $browser = (new ReflectionClass(browser::class))->newInstanceWithoutConstructor(); $typeDir = $tmp . '/shared/assets/images'; - foreach (['modx' => evo(), 'typeDir' => $typeDir, 'typeURL' => 'https://example.test/assets/images', - 'type' => 'images', 'config' => ['uploadDir' => $tmp . '/shared/assets']] as $name => $value) { + // typeURL as KCFinder derives a relative rb_base_url (against the browser script): never the folder + foreach (['modx' => evo(), 'typeDir' => $typeDir, 'typeURL' => '/manager/media/browser/mcpuk/assets/images', + 'type' => 'images', 'config' => ['uploadDir' => $tmp . '/shared/assets', '_siteUploadURL' => 'assets']] as $name => $value) { (new ReflectionProperty(uploader::class, $name))->setValue($browser, $value); } (new ReflectionProperty(browser::class, 'protectedPaths'))->setValue($browser, []); @@ -157,7 +158,7 @@ function evolutionCMS() { return evo(); } 'wrongTargetKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( EVO_BASE_PATH, $typeDir, $open, EVO_BASE_PATH . 'assets/local' ), - 'unconfinedKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( + 'outsideSpellingKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( EVO_BASE_PATH, $typeDir, $open, $typeDir ), 'personalKey' => EvolutionCMS\Support\FileBrowserAccess::resolveAclPath( @@ -170,7 +171,8 @@ function evolutionCMS() { return evo(); } $output['memberSubtree'] = $invoke('subtreeAccessFilter', $typeDir)($secret); $output['memberDescendants'] = $invoke('hasInaccessibleDescendants', $typeDir); - (new ReflectionProperty(uploader::class, 'typeURL'))->setValue($browser, 'https://example.test/unmapped/images'); + $setConfig = static fn (array $config) => (new ReflectionProperty(uploader::class, 'config'))->setValue($browser, $config); + $setConfig(['uploadDir' => $tmp . '/shared/assets', '_siteUploadURL' => 'unmapped']); $output['unmappedKey'] = $invoke('getFileGroupsRelPath', $open); $output['unmappedRead'] = $invoke('isPathAccessible', $open); $output['unmappedWrite'] = $invoke('isWriteAllowed', 'public'); @@ -179,6 +181,32 @@ function evolutionCMS() { return evo(); } $output['unmappedDescendants'] = $invoke('hasInaccessibleDescendants', $typeDir); $output['unmappedLink'] = $invoke('isLinkVisible', $typeDir . '/escape'); $output['log'] = evo()->log; + + // A narrowed site-wide filemanager_path: rows can only name what lies below it, so a type root + // the site serves from outside it carries no groups, linked to shared storage or not. + evo()->configGlobal['filemanager_path'] = EVO_BASE_PATH . 'assets/local'; + $setConfig(['uploadDir' => $tmp . '/shared/assets', '_siteUploadURL' => 'assets']); + $output['narrowedSharedKey'] = $invoke('getFileGroupsRelPath', $secret); + $output['narrowedSharedRead'] = $invoke('isPathAccessible', $secret); + $pictures = EVO_BASE_PATH . 'assets/pictures'; + file_put_contents($pictures . '/cat.jpg', 'cat'); + foreach (['typeDir' => $pictures, 'type' => 'pictures'] as $name => $value) { + (new ReflectionProperty(uploader::class, $name))->setValue($browser, $value); + } + $setConfig(['uploadDir' => EVO_BASE_PATH . 'assets', '_siteUploadURL' => 'assets']); + $output['narrowedPlainKey'] = $invoke('getFileGroupsRelPath', $pictures . '/cat.jpg'); + $output['narrowedPlainRead'] = $invoke('isPathAccessible', $pictures . '/cat.jpg'); + $output['narrowedPlainWrite'] = $invoke('isWriteAllowed', ''); + $output['narrowedPlainListing'] = array_map('basename', $invoke('filterAccessiblePaths', [$pictures . '/cat.jpg'])); + $setConfig(['uploadDir' => EVO_BASE_PATH . 'assets', '_siteUploadURL' => 'elsewhere']); + $output['narrowedUnprovenKey'] = $invoke('getFileGroupsRelPath', $pictures . '/cat.jpg'); + $output['narrowedDiagnostic'] = EvolutionCMS\Support\FileBrowserAccess::isUsableRoot( + EVO_BASE_PATH . 'assets', 'assets/', EVO_BASE_PATH . 'assets/local', EVO_BASE_PATH, '/', EVO_SITE_URL + ); + unset(evo()->configGlobal['filemanager_path']); + foreach (['typeDir' => $typeDir, 'type' => 'images'] as $name => $value) { + (new ReflectionProperty(uploader::class, $name))->setValue($browser, $value); + } $_SESSION['mgrRole'] = 1; $output['adminRead'] = $invoke('isPathAccessible', $open); $output['adminEscape'] = $invoke('isPathAccessible', $typeDir . '/escape'); diff --git a/core/tests/Unit/Security/SharedBrowserAccessTest.php b/core/tests/Unit/Security/SharedBrowserAccessTest.php index 7019869bc1..fdf1cf82f0 100644 --- a/core/tests/Unit/Security/SharedBrowserAccessTest.php +++ b/core/tests/Unit/Security/SharedBrowserAccessTest.php @@ -78,7 +78,9 @@ function sharedBrowserResult(): array ->and($result['unmappedDiagnostic'])->toBeFalse(); expect($result['standardDiagnostic'])->toBeTrue() ->and($result['wrongTargetKey'])->toBeNull() - ->and($result['unconfinedKey'])->toBeNull(); + // a spelling outside the ACL root names nothing a row could restrict (the browser derives + // it from the configured URL below the site root, so this needs a narrowed filemanager_path) + ->and($result['outsideSpellingKey'])->toBe(''); }); it('does not derive local roots from unsafe or remote URLs', function (string $url) { @@ -113,3 +115,20 @@ function sharedBrowserResult(): array ->toBe($root . '/assets/images'); } }); + +it('maps the shared root from the configured upload URL, not from KCFinder typeURL', function () { + // the fixture gives typeURL the value KCFinder computes for rb_base_url "assets/" + expect(sharedBrowserResult()['key'])->toBe('assets/images/private/secret.txt'); +}); + +it('leaves type roots outside a narrowed filemanager_path open as before, but not unproven ones', function () { + $result = sharedBrowserResult(); + expect($result['narrowedPlainKey'])->toBe('') + ->and($result['narrowedPlainRead'])->toBeTrue() + ->and($result['narrowedPlainWrite'])->toBeTrue() + ->and($result['narrowedPlainListing'])->toBe(['cat.jpg']) + ->and($result['narrowedSharedKey'])->toBe('') + ->and($result['narrowedSharedRead'])->toBeTrue() + ->and($result['narrowedUnprovenKey'])->toBeNull() + ->and($result['narrowedDiagnostic'])->toBeTrue(); +}); diff --git a/manager/media/browser/mcpuk/config.php b/manager/media/browser/mcpuk/config.php index be59b1d9e3..57094f3ebd 100755 --- a/manager/media/browser/mcpuk/config.php +++ b/manager/media/browser/mcpuk/config.php @@ -37,6 +37,9 @@ 'theme' => "evo", 'uploadURL' => $uploadUrl, 'uploadDir' => $uploadDir, + // the URL as configured: the uploader rewrites uploadURL against the script's cwd and + // DOCUMENT_ROOT. A leading "_" keeps it out of the session override. + '_siteUploadURL' => $uploadUrl, 'siteURL' => EVO_SITE_URL, 'assetsURL' => $uploadUrl, 'dirPerms' => intval(EvolutionCMS()->getConfig('new_folder_permissions'), 8), diff --git a/manager/media/browser/mcpuk/core/browser.php b/manager/media/browser/mcpuk/core/browser.php index c447495694..abf5dd2f5e 100755 --- a/manager/media/browser/mcpuk/core/browser.php +++ b/manager/media/browser/mcpuk/core/browser.php @@ -1984,8 +1984,13 @@ protected function getFileGroupsRelPath(string $absPath): ?string $aclRoot, $this->typeDir, $absPath, + // not $this->typeURL: KCFinder resolves a relative upload URL against the browser + // script (or gives up when DOCUMENT_ROOT is a symlink), so it never names the folder \EvolutionCMS\Support\FileBrowserAccess::localUploadPath( - (string) $this->typeURL, EVO_BASE_PATH, EVO_BASE_URL, EVO_SITE_URL + rtrim((string) ($this->config['_siteUploadURL'] ?? ''), '/') . '/' . $this->type, + EVO_BASE_PATH, + EVO_BASE_URL, + EVO_SITE_URL ) ); } @@ -2029,7 +2034,7 @@ protected function filterAccessiblePaths(array $absPaths): array } $relativePath = $itemRelativePaths[$ap]; if ($relativePath === '') { - $result[] = $ap; // the ACL root itself has no per-entry row + $result[] = $ap; // the ACL root itself, or a folder outside it: no row applies continue; } if (\EvolutionCMS\Support\FileManagerAccess::isAccessible($relativePath, $userGroups, $restricted)) { From c8f817cb81f55006d9b4edbefbd9e7579b09a8c9 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 3 Nov 2026 06:15:54 +0100 Subject: [PATCH 04/50] upd(file-manager): pass operator links to somewhere new in both file managers Links below a file manager root are made by the operator, never by the CMS: a shared upload folder, a plugin checked out elsewhere, images from another disk. A link whose real target exists and is new (not inside or around the ACL root, not a loop, not inside anything the walk already named or a shared type folder) is now a folder or file like any other, named by its own path, with every rule applied by that name: protected folders, file groups, the executable-name rule and the media browser type folder. Links that dangle, loop or would give a file a second name stay inert entries, and `..` past a link is refused. Both managers key a shared type folder the same way, so the classic manager can now enter it and edit its groups; the link that stands for a type folder is never deleted, renamed or unlinked. Removing any other passable link removes the link only and keeps its groups. A new fixture builds the media browser the way a web request does ($_SERVER, the working directory of browse.php, config.php), through a public_html -> evolution -> release chain, which is how the typeURL bug slipped through. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 13 +- core/functions/actions/files.php | 387 ++++++++++++++++-- core/src/Support/FileBrowserAccess.php | 123 ++++-- .../Fixtures/shared-type-root-request.php | 302 ++++++++++++++ .../FileManagerAclBypassSourceTest.php | 10 +- .../Security/FileManagerCodeRemovalTest.php | 10 +- .../Security/FileManagerLinkDisplayTest.php | 5 +- .../FileManagerPathContainmentTest.php | 20 +- .../Security/FileManagerSymlinkWritesTest.php | 3 +- .../MediaBrowserSymlinkContainmentTest.php | 15 +- .../Unit/Security/SharedBrowserAccessTest.php | 14 +- .../Security/SharedTypeRootRequestTest.php | 111 +++++ manager/actions/files.dynamic.php | 14 +- manager/media/browser/mcpuk/core/browser.php | 46 +-- .../media/browser/mcpuk/lib/helper_dir.php | 5 + 15 files changed, 953 insertions(+), 125 deletions(-) create mode 100644 core/tests/Fixtures/shared-type-root-request.php create mode 100644 core/tests/Unit/Security/SharedTypeRootRequestTest.php diff --git a/AGENTS.md b/AGENTS.md index 4f0c0bf796..ca19e8c4e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -419,8 +419,8 @@ A Windows device name (`CON`, `NUL`, `COM1.jpg` ...; `fileManagerIsReservedDevic The executable-name ban does not depend on those lists. `textsave()` applies it too: an existing executable file cannot be edited without the code permission. Nor can it be removed or displaced: whoever may not write code may not delete, rename or move it either. Deleting or renaming a file or -link of an executable name, or a folder holding one at any depth (links count by their own name and -are not followed), needs the code permission in both file managers (`fileManagerCodeRemovalDenied()` +link of an executable name, or a folder holding one at any depth (a link the file managers pass is +a folder whose code counts; any other link counts by its own name and is not followed), needs the code permission in both file managers (`fileManagerCodeRemovalDenied()` in the classic one, `refuseCodeRemoval()` in the media browser, both on `fileManagerCodeEntry()`). The name check also catches the spellings Windows turns into such a file: trailing dots and spaces (`.htaccess.`) and NTFS streams (`.htaccess:x`, `shell.php::$DATA`). @@ -432,7 +432,7 @@ The name check also catches the spellings Windows turns into such a file: traili | Browse or enter a path | membership in a group of every restricted ancestor folder | | Modify or delete an existing file | the same, and the file is not at the top level | | Rename, move or delete an existing entry (media browser too) | the top-level rule of the classic manager, measured from the manager's own `filemanager_path` (`canModifyExisting()` in `browser.php`) | -| Delete a link | removing a symlink or junction never forgets the groups stored under its target's key (the key of a link is its target's) | +| Delete a link | removing a symlink or junction (a passable one through the folder or file delete, any other through `deletelink`) removes the link only and never forgets the groups stored under its name, which apply again if the link returns | | Upload over an existing file | the file's own restrictions, not just the folder's (`fileManagerCanModifyExistingPath()`); the file keeps them and is **not** given the folder's groups | | Copy / duplicate (both file managers) | access is an AND over the restricted folders on the way and the file itself, each an OR over its groups, which one set of groups on one key cannot say. `FileManagerAccess::carriedRestrictions()` keeps the groups that satisfy every set the destination does not already enforce; the copy is refused when there are none, and when the destination is the ACL root itself or lies outside it (no per-entry row can be stored there). Never store the union of the groups. A copy takes `replaceRestrictions()` and a rename or move takes `moveRestrictions()`, which first drops rows left at the free destination by files removed outside the CMS. Rows are matched by the exact stored path (`exactRows()`), and the `file_groups.file` column is case-sensitive (`utf8mb4_bin` on MySQL), because on a case-sensitive file system `report.txt` and `REPORT.txt` are two files | | Any name the media browser writes (copy, move, upload, thumbnail) | the destination is free, a dangling symlink counts as taken (`isTaken()`), and copies are made exclusively with `copyExclusive()` (`fopen` mode `x`), never `copy()` through a link | @@ -444,12 +444,13 @@ The name check also catches the spellings Windows turns into such a file: traili **Always enforced** -- Paths outside `filemanager_path` are rejected; `..`, symlinks and Windows junctions are not followed. -- A path that passes through a symlink or junction below the root is rejected as a whole (`fileManagerPathContainsLink()`): in the classic manager, uploads, direct edit and delete, and the media browser (`isInsideTypeDir()`). Both file managers list a link as an inert entry: no web URL, drill-down, thumbnail or selection (a link may lead anywhere, and the web server would serve its target unchecked). The one action is removing the link itself (`mode=deletelink`, `act_deleteLink`), judged by the link's own path, never resolved: the top-level and group rules of an existing entry, and no link where a protected folder belongs; the target and its groups stay. Its target is shown only as a path relative to the link, and only when it lies inside the manager's root (the media browser: the type folder) and the manager may reach it (`fileManagerRelativeLinkTarget()`); a `../../` or absolute target outside, or a restricted one, is shown as "outside the file manager or not accessible", never as a path. Links are never created: a link entry in a ZIP is not extracted (neither as a link nor as a file holding its path) but reported (`fileManagerExtractZip()` `$links`), a directory archive stores a link as a link entry with that same relative target or leaves it out, and a folder copy leaves it out. The media browser serves no thumbnail whose cached path or original runs through one. Nor does it write, rename, copy, move or empty the thumbnail cache through one (`isSafeThumbPath()`, measured from the upload root, so a link at the cache root itself is refused too, before anything is probed or created). +- Paths outside `filemanager_path` are rejected, and `..` never climbs above the root. +- Links below a root are the operator's (the CMS never creates one), so a link to somewhere new is a folder (or file) like any other: a shared upload folder, a plugin checked out elsewhere, images linked in from another disk. Absolute or relative, whatever its spelling, it is judged by its real target (`fileManagerPassableLinkTarget()`), and passed when that target exists and is new: not inside or around the ACL root, not around the folder the link sits in (a loop), not inside or around anything the walk has named already (where it started, the links it passed), not inside shared storage that a type-folder link names (`fileManagerSharedTypeRoots()`). Any of those would give a file a second name, and file groups are keyed by name, so a group on `team` would not hold `public/link-to-team`. A passed link is named by its own path, never resolved (`fileManagerCanonicalPath()`, which also puts each part into its stored letter case and accepts any spelling of the root: a hosting `public_html` chain or a release link above it), and every rule applies by that name: the folders of layer 1 (`fileManagerProtectedPaths()` holds each by its real path and by its name, so a protected folder may itself be a link), file groups, the executable-name rule, the type folder of the media browser. Past a link, `..` is refused: it climbs in the link's target, not back where the server walks. Two separate links to one outside folder cannot be told apart from here; the operator should not make them, and a server without `FollowSymLinks` does not serve what the CMS then opens. Tests: `SharedTypeRootRequestTest.php` (a web request through the real browser constructor), `SharedBrowserAccessTest.php`. +- A path that passes through any other link below the root is rejected as a whole (`fileManagerPathContainsLink()`): in the classic manager, uploads, direct edit and delete, and the media browser (`isInsideTypeDir()`); a write never goes to a path that is itself a link, dangling or not (`fileManagerIsSafeWriteTarget()`). Both file managers list such a link as an inert entry: no web URL, drill-down, thumbnail or selection (a link may lead anywhere, and the web server would serve its target unchecked). The one action is removing the link itself (`mode=deletelink`, `act_deleteLink`), judged by the link's own path, never resolved: the top-level and group rules of an existing entry, and no link where a protected folder belongs; the target and its groups stay. Its target is shown only as a path relative to the link, and only when it lies inside the manager's root (the media browser: the type folder) and the manager may reach it (`fileManagerRelativeLinkTarget()`); a `../../` or absolute target outside, or a restricted one, is shown as "outside the file manager or not accessible", never as a path. Links are never created: a link entry in a ZIP is not extracted (neither as a link nor as a file holding its path) but reported (`fileManagerExtractZip()` `$links`), a directory archive stores a link as a link entry with that same relative target or leaves it out, and a folder copy leaves it out, passable or not. The media browser serves no thumbnail whose cached path or original runs through a link it does not pass. Nor does it write, rename, copy, move or empty the thumbnail cache through one (`isSafeThumbPath()`, measured from the upload root, so a link at the cache root itself is refused too, before anything is probed or created). - Path comparison (`FileManagerAccess::isWithin()`) ignores case on Windows, and ZIP entries are resolved to the stored letter case (`fileManagerCanonicalCase()`) before the checks. - The media browser (KCFinder) follows the same rules: it stays out of the folders of layer 1 (`isInProtectedFolder()`), applies the executable-name rule to every name it writes (`validateFilename()`, including the final name of an upload), its temporary download archives carry the `kcf-download-` prefix and are the only `.zip` files it ever cleans up, and a folder rename never replaces an existing destination. -- The media browser keys file groups by the site-side spelling of its type root (`getFileGroupsRelPath()`, `FileBrowserAccess::resolveAclPath()`), so a type root moved to shared storage (`rb_base_dir` outside the release, `assets/images` a symlink to it) keeps its `assets/images/...` rows. That spelling comes from the configured `rb_base_url` (`_siteUploadURL`), never from KCFinder's `typeURL`, which it resolves against the browser script. The symlink must resolve exactly to the type root; an unproven spelling inside the ACL root refuses every group-checked action and logs it, while a type root the site serves from outside the ACL root (a narrowed `filemanager_path`) carries no groups, as anything else outside it. Test: `core/tests/Unit/Security/SharedBrowserAccessTest.php`. +- The media browser keys file groups by the name of its type folder below the ACL root, then the path below it by name (`getFileGroupsRelPath()`, `FileBrowserAccess::resolveAclPath()`), so a type folder moved to shared storage (`rb_base_dir` outside the release, `assets/images` a link to it) keeps its `assets/images/...` rows, the same keys the classic manager uses for it. The type folder is named by its real path below the ACL root, else by a passable link to it below the root, else by the site-side spelling of the configured `rb_base_url` (`_siteUploadURL`) once realpath proves it is the same folder; never by KCFinder's `typeURL`, which it resolves against the browser script. An unproven name inside the ACL root refuses every group-checked action and logs it, while a type folder the site serves from outside the ACL root (a narrowed `filemanager_path`) carries no groups, as anything else outside it. In the classic manager the link that stands for a type folder (`fileManagerSharedTypeRootAt()`) is listed as the folder and entered, but never deleted, renamed or unlinked. - ZIP entries with absolute paths, `:` or `..` segments are skipped. **Refusals are logged.** Every refusal above is written to the event log (a warning from source `File manager`, with the manager as its user), where administrators read it: `fileManagerLogDenied()` / `fileManagerDenied()` in the classic manager and its ZIP extraction, `logDenied()` in the media browser. Log the rule that refused, not routine misses (an extension that is merely not on the allowed list is not logged; an executable name is). Values from the request are escaped, one line per refusal, at most 20 per request. A new refusal that only returns a message is incomplete. diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php index dd46c7985e..6207080315 100644 --- a/core/functions/actions/files.php +++ b/core/functions/actions/files.php @@ -1,4 +1,5 @@ getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH); + $userRoot = fileManagerCanonicalPath(evolutionCMS()->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH); $roots = [FileManagerAccess::aclRoot(), rtrim(str_replace('\\', '/', $userRoot), '/')]; } @@ -220,11 +221,10 @@ function fileManagerResolvePath($filemanagerPath, $requestedPath) return null; } - $path = realpath($requested); + $path = fileManagerCanonicalPath($requested); if ($path === false) { return null; } - $path = rtrim(str_replace('\\', '/', $path), '/'); if (!FileManagerAccess::isWithin($filemanagerPath, $path)) { return null; } @@ -275,10 +275,15 @@ function fileManagerProtectedPaths() } } - return array_map( - static fn ($path) => rtrim(str_replace('\\', '/', realpath($path) ?: $path), '/'), - $paths - ); + // by its real path and by its name below the root: a protected folder may be a link + $spellings = []; + foreach ($paths as $path) { + foreach ([realpath($path) ?: $path, fileManagerCanonicalPath($path) ?: $path] as $spelling) { + $spellings[] = rtrim(str_replace('\\', '/', $spelling), '/'); + } + } + + return array_values(array_unique($spellings)); } } @@ -366,9 +371,25 @@ function fileManagerCodeEntry($path) if (fileManagerIsExecutableName($path)) { return ''; } - if (fileManagerIsLink($path) || !is_dir($path)) { + // a link the file managers pass is a folder whose code goes with it (a plugin checked out + // elsewhere); any other link counts by its own name only. Each real folder is read once. + $named = []; + $passes = static function ($link) use (&$named) { + $target = fileManagerPassableLinkTarget($link, $named); + if ($target === null || !is_dir($target)) { + return false; + } + $named[] = $target; + + return true; + }; + if (!is_dir($path) || (fileManagerIsLink($path) && !$passes($path))) { return null; } + $real = realpath($path); + if ($real !== false) { + $named[] = rtrim(str_replace('\\', '/', $real), '/'); + } $stack = ['']; while ($stack) { $rel = array_pop($stack); @@ -388,7 +409,7 @@ function fileManagerCodeEntry($path) if (fileManagerIsReservedDeviceName($entry)) { continue; } - if (!fileManagerIsLink("$path/$entryRel") && is_dir("$path/$entryRel")) { + if (is_dir("$path/$entryRel") && (!fileManagerIsLink("$path/$entryRel") || $passes("$path/$entryRel"))) { $stack[] = $entryRel; } } @@ -534,16 +555,276 @@ function fileManagerIsLink($path) } } +if(!function_exists('fileManagerSiteAclRoot')) { + /** + * FileManagerAccess::aclRoot() through evolutionCMS(), as everything here: the site-wide + * file manager root that file groups are keyed by, canonical, no trailing slash. Null without + * a CMS to ask (the helpers run on their own in tests): then no link is passed. + * + * @return string|null + */ + function fileManagerSiteAclRoot() + { + if (!defined('EVO_BASE_PATH')) { + return null; + } + try { + $evo = evolutionCMS(); + } catch (\Throwable $e) { + return null; + } + $root = $evo->configGlobal['filemanager_path'] ?? $evo->getConfig('filemanager_path'); + $root = str_replace('[(base_path)]', EVO_BASE_PATH, (string) $root); + + return rtrim(str_replace('\\', '/', realpath($root) ?: realpath(EVO_BASE_PATH)), '/'); + } +} + +if(!function_exists('fileManagerSharedTypeRoots')) { + /** + * The links that stand for media browser type folders kept in shared storage (see + * FileBrowserAccess::sharedTypeRoots()), for the site-wide settings. + * + * @return array [link => real folder] + */ + function fileManagerSharedTypeRoots() + { + static $cache = []; + static $computing = false; + $aclRoot = $computing ? null : fileManagerSiteAclRoot(); + if ($aclRoot === null) { + // while the list is being built, or without a CMS, it is empty + return []; + } + $evo = evolutionCMS(); + $setting = static fn ($key) => (string) ($evo->configGlobal[$key] ?? $evo->getConfig($key, '')); + $args = [ + $aclRoot, + $setting('rb_base_dir'), + $setting('rb_base_url'), + EVO_BASE_PATH, + defined('EVO_BASE_URL') ? EVO_BASE_URL : '/', + defined('EVO_SITE_URL') ? EVO_SITE_URL : '', + ]; + $key = implode("\0", $args); + if (!array_key_exists($key, $cache)) { + $computing = true; + try { + $cache[$key] = FileBrowserAccess::sharedTypeRoots(...$args); + } finally { + $computing = false; + } + } + + return $cache[$key]; + } +} + +if(!function_exists('fileManagerSharedTypeRootAt')) { + /** + * The shared type-folder link $path names, checked again against its target, or null. + * + * @param string $path absolute path in any spelling of its parent + * @return string|null the link as fileManagerSharedTypeRoots() lists it + */ + function fileManagerSharedTypeRootAt($path) + { + $roots = fileManagerSharedTypeRoots(); + if ($roots === []) { + return null; + } + $path = rtrim(str_replace('\\', '/', $path), '/'); + $parent = realpath(dirname($path)); + $real = realpath($path); + if ($parent === false || $real === false) { + return null; + } + $spelling = rtrim(str_replace('\\', '/', $parent), '/') . '/' . basename($path); + $real = rtrim(str_replace('\\', '/', $real), '/'); + foreach ($roots as $link => $target) { + // isWithin() both ways: the same path, by the platform's letter-case rule + if (FileManagerAccess::isWithin($link, $spelling) && FileManagerAccess::isWithin($spelling, $link) + && FileManagerAccess::isWithin($target, $real) && FileManagerAccess::isWithin($real, $target)) { + return $link; + } + } + + return null; + } +} + +if(!function_exists('fileManagerPassableLinkTarget')) { + /** + * Where a link below a file manager root leads, when the file managers may pass it: then it + * is a folder (or file) like any other, named by its own path, and every rule applies by that + * name. The CMS never creates links, so these are the operator's: a shared upload folder, a + * plugin checked out elsewhere. + * + * A link is passed only into somewhere new. Not when it dangles; not into the ACL root or + * around it; not around the folder it sits in (a loop); not into or around anything the walk + * has named already ($named: where it started and the links it passed); not into shared + * storage that a type-folder link names. Each of those would give a file a second name, and + * file groups are keyed by name. Two separate links to one outside folder cannot be told + * apart from here: the operator should not make them. + * + * @param string $link + * @param string[] $named real paths, forward slashes + * @return string|null its real target, forward slashes + */ + function fileManagerPassableLinkTarget($link, array $named = []) + { + $target = realpath($link); + $parent = realpath(dirname($link)); + if ($target === false || $parent === false) { + return null; + } + $aclRoot = fileManagerSiteAclRoot(); + if ($aclRoot === null) { + return null; + } + $target = rtrim(str_replace('\\', '/', $target), '/'); + $parent = rtrim(str_replace('\\', '/', $parent), '/'); + $overlaps = static fn ($a, $b) => FileManagerAccess::isWithin($a, $b) || FileManagerAccess::isWithin($b, $a); + if ($overlaps($aclRoot, $target) || FileManagerAccess::isWithin($target, $parent)) { + return null; + } + foreach ($named as $real) { + if ($overlaps($real, $target)) { + return null; + } + } + $self = fileManagerSharedTypeRootAt($link); + foreach (fileManagerSharedTypeRoots() as $typeLink => $typeTarget) { + if ($typeLink !== $self && $overlaps($typeTarget, $target)) { + return null; + } + } + + return $target; + } +} + +if(!function_exists('fileManagerStoredName')) { + /** + * $name with the letter case $dir stores it with. Windows matches names in any case, while + * file groups are matched exactly, so a name from a request is put into the stored case + * before it becomes a key. Elsewhere $name itself. + * + * @param string $dir + * @param string $name + */ + function fileManagerStoredName($dir, $name) + { + static $listings = []; + if (PHP_OS_FAMILY !== 'Windows') { + return $name; + } + if (!array_key_exists($dir, $listings)) { + $listings[$dir] = @scandir($dir) ?: []; + } + if (in_array($name, $listings[$dir], true)) { + return $name; + } + foreach ($listings[$dir] as $entry) { + if (strcasecmp($entry, $name) === 0) { + return $entry; + } + } + + return $name; + } +} + +if(!function_exists('fileManagerCanonicalPath')) { + /** + * The name the file managers know an existing $path by. Below the ACL root (by any spelling + * of it: a hosting public_html, a release link), it is the root's own path followed by each + * part in its stored letter case, through passable links but never resolving them, since a + * linked folder is named by its link (fileManagerPassableLinkTarget()). Elsewhere realpath(). + * + * @param string $path + * @param string|null $root the ACL root, fileManagerSiteAclRoot() by default + * @return string|false forward slashes, no trailing slash; false when it does not exist, + * climbs above the root or out of a link, or passes a link not passed + */ + function fileManagerCanonicalPath($path, $root = null) + { + $path = rtrim(str_replace('\\', '/', (string) $path), '/'); + if ($path === '' || strpos($path, "\0") !== false || fileManagerRefuseReservedName($path)) { + return false; + } + $root = $root === null + ? fileManagerSiteAclRoot() + : rtrim(str_replace('\\', '/', realpath($root) ?: $root), '/'); + if ($root === null) { + $real = realpath($path); + + return $real === false ? false : rtrim(str_replace('\\', '/', $real), '/'); + } + $segments = explode('/', $path); + $rest = null; + $lead = ''; + // the shortest leading part of $path that is the ACL root + foreach ($segments as $i => $segment) { + $lead = $i === 0 ? $segment : $lead . '/' . $segment; + if ($lead === '' || substr($lead, -1) === ':') { + continue; // "/" or a bare drive letter, which realpath() reads as the working directory + } + $real = realpath($lead); + if ($real !== false && FileManagerAccess::isWithin($root, $real) && FileManagerAccess::isWithin($real, $root)) { + $rest = array_slice($segments, $i + 1); + break; + } + } + if ($rest === null) { + $real = realpath($path); + + return $real === false ? false : rtrim(str_replace('\\', '/', $real), '/'); + } + + $current = $root; + $named = [$root]; + $throughLink = false; + foreach ($rest as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + if ($segment === '..') { + // past a link, .. climbs in its target, not back where the server walks + if ($current === $root || $throughLink) { + return false; + } + $current = dirname($current); + continue; + } + $next = $current . '/' . fileManagerStoredName($current, $segment); + if (fileManagerIsLink($next)) { + $target = fileManagerPassableLinkTarget($next, $named); + if ($target === null) { + return false; + } + $named[] = $target; + $throughLink = true; + } elseif (!file_exists($next)) { + return false; + } + $current = $next; + } + + return $current; + } +} + if(!function_exists('fileManagerPathContainsLink')) { /** - * Whether a path below $root passes through a symlink or junction. The root itself is a - * configured boundary and may be a symlink (for example, public_html); links below it are - * never followed. Accept either the configured root spelling or its canonical spelling. + * Whether a path below $root cannot be used: it climbs above $root, names a Windows device, + * or passes a link the file managers do not pass (fileManagerPassableLinkTarget(): one that + * dangles, loops, or gives something reachable a second name). The root itself is a + * configured boundary and may be a link (for example, public_html). Accept either the + * configured root spelling or its canonical spelling. * * @param string $root absolute root * @param string $path absolute path, possibly not created yet - * - * A Windows device name below the root counts as a link here: callers refuse both. */ function fileManagerPathContainsLink($root, $path) { @@ -567,12 +848,15 @@ function fileManagerPathContainsLink($root, $path) $current = $base; $relative = substr($path, strlen($base)); + $named = $canonicalRoot !== '' ? [$canonicalRoot] : []; + $throughLink = false; foreach (explode('/', trim($relative, '/')) as $segment) { if ($segment === '' || $segment === '.') { continue; } if ($segment === '..') { - if ($current === $base) { + // past a link, .. climbs in its target, not back where the server walks + if ($current === $base || $throughLink) { return true; } $current = dirname($current); @@ -581,7 +865,12 @@ function fileManagerPathContainsLink($root, $path) $current = rtrim($current, '/') . '/' . $segment; if (fileManagerIsLink($current)) { - return true; + $target = fileManagerPassableLinkTarget($current, $named); + if ($target === null) { + return true; + } + $named[] = $target; + $throughLink = true; } } @@ -592,8 +881,9 @@ function fileManagerPathContainsLink($root, $path) if(!function_exists('fileManagerIsSafeWriteTarget')) { /** * Whether writing to $target, which need not exist yet, stays under $root. Every part of - * the path that already exists is checked, so a symlink anywhere on the way (a folder - * whose children do not exist yet, or the file itself) cannot redirect the write. + * the path that already exists is checked: a link on the way must be one the file managers + * pass (fileManagerPassableLinkTarget()), and the target itself must not be a link, dangling + * or not, which the write would follow. * * @param string $root canonical absolute path * @param string $target absolute path below $root @@ -610,6 +900,8 @@ function fileManagerIsSafeWriteTarget($root, $target) return false; } + $rootReal = realpath($root); + $named = $rootReal !== false ? [rtrim(str_replace('\\', '/', $rootReal), '/')] : []; $current = $root; foreach (explode('/', substr($target, strlen($root) + 1)) as $segment) { if ($segment === '' || $segment === '.' || $segment === '..') { @@ -617,7 +909,10 @@ function fileManagerIsSafeWriteTarget($root, $target) } $current .= '/' . $segment; if (fileManagerIsLink($current)) { - return false; + if ($current === $target || ($real = fileManagerPassableLinkTarget($current, $named)) === null) { + return false; + } + $named[] = $real; } if (!file_exists($current)) { // everything below is created by this write, so none of it can be a link @@ -887,8 +1182,8 @@ function fileManagerCreateDirectoryZip( array $fileGroupsMap, array $protectedPaths ): bool { - $sourcePath = rtrim(str_replace('\\', '/', realpath($sourcePath)), '/'); - $filemanagerPath = rtrim(str_replace('\\', '/', realpath($filemanagerPath)), '/'); + $sourcePath = rtrim((string) fileManagerCanonicalPath($sourcePath), '/'); + $filemanagerPath = rtrim((string) fileManagerCanonicalPath($filemanagerPath), '/'); $archiveRoot = basename($relativePath !== '' ? $relativePath : $sourcePath); $archiveRoot = trim(preg_replace('/[^\w.-]+/', '-', $archiveRoot), '.-') ?: 'files'; @@ -1180,7 +1475,7 @@ function fileManagerCanonicalCase($path) $path = rtrim(str_replace('\\', '/', $path), '/'); $rest = []; $existing = $path; - while ($existing !== '' && ($real = realpath($existing)) === false) { + while ($existing !== '' && ($real = fileManagerCanonicalPath($existing)) === false) { $rest[] = basename($existing); $parent = dirname($existing); if ($parent === $existing) { @@ -1262,7 +1557,7 @@ function fileManagerZipWriteGuard($filemanagerPath, $destination): callable function fileManagerExtractZip($file, $path, array $protectedPaths = [], $dirMode = 0777, ?callable $mayWrite = null, ?array &$created = null, ?array &$links = null) { $links = []; - $root = realpath($path); + $root = fileManagerCanonicalPath($path); if ($root === false) { return false; } @@ -1493,7 +1788,15 @@ function ls($curpath, array $options = []) } $rel_newpath = ltrim(substr($newpath, strlen($filemanager_path)), '/'); $rel_web = ltrim(substr($newpath, strlen($base_path)), '/'); - if (fileManagerIsLink($newpath)) { + // a link the file managers pass is listed as the folder or file it stands for, named by + // its own path (fileManagerPassableLinkTarget()); the site's shared type folder among them + $isLink = fileManagerIsLink($newpath); + $passableLink = $isLink && !fileManagerPathContainsLink($filemanager_path, $newpath); + $sharedTypeRoot = $passableLink && fileManagerSharedTypeRootAt($newpath) !== null; + $linkMark = $passableLink + ? ' ' + : ''; + if ($isLink && !$passableLink) { // shown, never followed: a link may lead anywhere, and the web server would serve its // target without any check here, so it gets no URL, no drill-down and no action if (fileManagerIsAccessible($rel_newpath, $userGroups ?? [], $fileGroupsMap ?? [])) { @@ -1535,7 +1838,7 @@ function ls($curpath, array $options = []) continue; } elseif (!in_array($file, $excludes) && !in_array($newpath, $protected_path)) { $dirs_array[$dircounter]['text'] = ' ' - . '' + . $linkMark . '' . htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '' . $groupSuffix; $dfiles = scandir($newpath); @@ -1548,7 +1851,8 @@ function ls($curpath, array $options = []) } } $file_exists = (0 < count($dfiles)) ? 'file_exists' : ''; - $canModifyDir = $currentDirWritable + // the site's shared type folder is entered, never removed or renamed + $canModifyDir = $currentDirWritable && !$sharedTypeRoot && fileManagerCanModifyExistingPath($rel_newpath, $userGroups ?? [], $fileGroupsMap ?? []) && is_writable($newpath); @@ -1569,7 +1873,7 @@ function ls($curpath, array $options = []) . $_style['icon_trash'] . '" title="' . $_lang['file_delete_folder'] . '">' : ''; } - $dirs_array[$dircounter]['rename'] = ($currentDirWritable + $dirs_array[$dircounter]['rename'] = ($currentDirWritable && !$sharedTypeRoot && fileManagerCanModifyExistingPath($rel_newpath, $userGroups ?? [], $fileGroupsMap ?? []) && is_writable($newpath)) ? 'Invalid path.

'; } - $filename = str_replace('\\', '/', realpath($requested)); + $filename = (string) fileManagerCanonicalPath($requested); if (!FileManagerAccess::isWithin($filemanager_path, $filename) || !is_file($filename)) { return 'Invalid path.

'; } @@ -2142,14 +2445,14 @@ function delete_file() { global $_lang; - $filemanager_path = rtrim(str_replace('\\', '/', realpath(evolutionCMS() - ->getConfig('filemanager_path', EVO_BASE_PATH))), '/'); + $filemanager_path = rtrim((string) fileManagerCanonicalPath(evolutionCMS() + ->getConfig('filemanager_path', EVO_BASE_PATH)), '/'); $requested_path = ltrim($_REQUEST['path'] ?? '', '/'); $requested = $filemanager_path . '/' . $requested_path; if (fileManagerPathContainsLink($filemanager_path, $requested)) { return 'Invalid path.

'; } - $file = str_replace('\\', '/', realpath($requested)); + $file = (string) fileManagerCanonicalPath($requested); if (!FileManagerAccess::isWithin($filemanager_path, $file) || !is_file($file)) { return 'Invalid path.

'; } @@ -2165,11 +2468,15 @@ function delete_file() // the path below the root, never the server's absolute one $msg = sprintf($_lang['deleting_file'], htmlspecialchars($fileRel, ENT_QUOTES, 'UTF-8')); + // a linked file goes as a link (unlink() never follows one): its groups stay for its return + $wasLink = fileManagerIsLink($file); if (!evolutionCMS()->hasPermission('file_manager') || !@unlink($file)) { $msg .= '' . $_lang['file_not_deleted'] . '

'; } else { $msg .= '' . $_lang['file_deleted'] . '

'; - \EvolutionCMS\Support\FileManagerAccess::forgetRestrictions(fileManagerAclKey($fileRel)); + if (!$wasLink) { + \EvolutionCMS\Support\FileManagerAccess::forgetRestrictions(fileManagerAclKey($fileRel)); + } } // Log the change diff --git a/core/src/Support/FileBrowserAccess.php b/core/src/Support/FileBrowserAccess.php index e2b96b4814..f87491bda1 100644 --- a/core/src/Support/FileBrowserAccess.php +++ b/core/src/Support/FileBrowserAccess.php @@ -11,6 +11,9 @@ final class FileBrowserAccess { private const IMAGE_TYPES = ['images', 'image']; + /** Folders below rb_base_dir that the browser opens as types (config.php `types`). */ + public const TYPE_FOLDERS = ['files', 'images', 'file', 'image', 'media']; + /** * The manager permission that opens a browser type, besides file_manager which opens all. * The browser falls back to its first type (files) for a missing or unknown one, so those @@ -124,10 +127,13 @@ public static function localUploadPath(string $url, string $basePath, string $ba } /** - * Keep file_groups keys stable when a deployment exposes a configured shared type root. - * Only the type root may be an alias: the browser must separately reject every symlink - * below it. The site-side root is accepted only when realpath proves it is the same folder. - * No matching root returns null; callers must not interpret that as a public file. + * The file_groups key of a path in a browser type folder: the type folder's name below the + * site-wide ACL root, then the path below it by name. Links below the type folder count by + * their own name when the file managers pass them (fileManagerPassableLinkTarget()); any + * other link makes the path unusable. The type folder itself is named by its real path when + * that lies below the ACL root, else by a link to it below the root (a shared folder), else + * by the site-side spelling of rb_base_url ($localTypeDir) once realpath proves it is the + * same folder. No name returns null; callers must not interpret that as a public file. * * @return string|null Key relative to the site-wide ACL root; an empty key is that root itself, * or a type root the site serves from outside it, where no group applies. @@ -150,40 +156,48 @@ public static function resolveAclPath(string $aclRoot, string $typeDir, string $ $typeDir = rtrim(str_replace('\\', '/', $typeDir), '/'); $absolutePath = rtrim(str_replace('\\', '/', $absolutePath), '/'); $spelling = FileManagerAccess::isWithin($typeDir, $absolutePath) ? $typeDir : $typeRoot; - if (!FileManagerAccess::isWithin($spelling, $absolutePath)) { - return null; - } - $suffix = ltrim(substr($absolutePath, strlen($spelling)), '/'); - if (in_array('..', explode('/', $suffix), true) || strpos($suffix, "\0") !== false - || \fileManagerPathContainsLink($typeDir, $absolutePath)) { + if (!FileManagerAccess::isWithin($spelling, $absolutePath) + || \fileManagerPathContainsLink($spelling, $absolutePath)) { return null; } - $existing = realpath($absolutePath); - if ($existing !== false) { - $existing = str_replace('\\', '/', $existing); - if (!FileManagerAccess::isWithin($typeRoot, $existing)) { - return null; + $parts = []; + $current = $spelling; + foreach (explode('/', substr($absolutePath, strlen($spelling))) as $segment) { + if ($segment === '' || $segment === '.') { + continue; } - $suffix = ltrim(substr($existing, strlen($typeRoot)), '/'); - } else { - $suffix = implode('/', array_filter(explode('/', $suffix), static fn ($part) => $part !== '' && $part !== '.')); + if ($segment === '..') { + // fileManagerPathContainsLink() let it through, so it stays below the type folder + array_pop($parts); + $current = dirname($current); + continue; + } + $segment = \fileManagerStoredName($current, $segment); + $parts[] = $segment; + $current .= '/' . $segment; } if (FileManagerAccess::isWithin($root, $typeRoot)) { $key = ltrim(substr($typeRoot, strlen($root)), '/'); + } elseif (($typeName = \fileManagerCanonicalPath($typeDir, $root)) !== false + && FileManagerAccess::isWithin($root, $typeName)) { + $key = ltrim(substr($typeName, strlen($root)), '/'); } elseif ($localTypeDir === null || ($localRoot = realpath($localTypeDir)) === false || !FileManagerAccess::isWithin($typeRoot, str_replace('\\', '/', $localRoot)) || !FileManagerAccess::isWithin(str_replace('\\', '/', $localRoot), $typeRoot)) { return null; - } elseif (FileManagerAccess::isWithin($root, $localTypeDir)) { - $key = ltrim(substr($localTypeDir, strlen($root)), '/'); - } else { + } elseif (($localName = \fileManagerCanonicalPath($localTypeDir, $root)) !== false + && FileManagerAccess::isWithin($root, $localName)) { + $key = ltrim(substr($localName, strlen($root)), '/'); + } elseif (!FileManagerAccess::isWithin($root, $localTypeDir)) { // the site serves this folder from outside the ACL root (a narrowed filemanager_path), // where no file_groups row can name it (FileManagerAccess::aclKey()): nothing applies return ''; + } else { + return null; // below the root, but only through a link the file managers do not pass } - return trim($key . '/' . $suffix, '/'); + return trim($key . '/' . implode('/', $parts), '/'); } /** @@ -209,7 +223,7 @@ public static function isUsableRoot(string $uploadDir, string $uploadUrl, string } $found = false; - foreach (['files', 'images', 'file', 'image', 'media'] as $type) { + foreach (self::TYPE_FOLDERS as $type) { $typeDir = rtrim($uploadDir, '/\\') . '/' . $type; if (!is_dir($typeDir)) { continue; @@ -223,4 +237,67 @@ public static function isUsableRoot(string $uploadDir, string $uploadUrl, string return $found; } + + /** + * Type folders a deployment keeps in shared storage, by the site-side link the file manager may + * pass: [link => real folder], both absolute with forward slashes. A link qualifies when it is + * the site-side spelling of a configured type folder (rb_base_url below the site root), lies + * below the ACL root with no other link on the way, and resolves exactly to that type folder + * (rb_base_dir/) outside the ACL root. Its key is the browser's (resolveAclPath()), so + * both file managers read and write the same file_groups rows. Links above the ACL root (a + * hosting public_html, a release switch) belong to the root and do not count. + * + * @return array + * @since 3.6.0 + */ + public static function sharedTypeRoots(string $aclRoot, string $uploadDir, string $uploadUrl, string $basePath, string $baseUrl, string $siteUrl): array + { + $uploadDir = rtrim(str_replace('\\', '/', str_replace('[(base_path)]', $basePath, $uploadDir)), '/'); + $aclRoot = str_replace('[(base_path)]', $basePath, $aclRoot); + if ($uploadDir === '' || strpos($uploadDir . $aclRoot, "\0") !== false + || \fileManagerRefuseReservedName($uploadDir) || \fileManagerRefuseReservedName($aclRoot)) { + return []; + } + $root = realpath($aclRoot); + if ($root === false) { + return []; + } + $root = rtrim(str_replace('\\', '/', $root), '/'); + + $links = []; + foreach (self::TYPE_FOLDERS as $type) { + $typeDir = $uploadDir . '/' . $type; + $real = realpath($typeDir); + if ($real === false || !is_dir($real)) { + continue; + } + $real = rtrim(str_replace('\\', '/', $real), '/'); + if (FileManagerAccess::isWithin($root, $real)) { + continue; // reachable without a link: nothing to pass + } + $local = self::localUploadPath(rtrim($uploadUrl, '/') . '/' . $type, $basePath, $baseUrl, $siteUrl); + $key = $local === null ? null : self::resolveAclPath($root, $typeDir, $typeDir, $local); + if ($key === null || $key === '') { + continue; + } + $link = $root . '/' . $key; + if (!\fileManagerIsLink($link)) { + continue; + } + // only the type folder itself may be the link + $parent = $root; + foreach (explode('/', dirname($key)) as $segment) { + if ($segment === '.' || $segment === '') { + continue; + } + $parent .= '/' . $segment; + if (\fileManagerIsLink($parent)) { + continue 2; + } + } + $links[$link] = $real; + } + + return $links; + } } diff --git a/core/tests/Fixtures/shared-type-root-request.php b/core/tests/Fixtures/shared-type-root-request.php new file mode 100644 index 0000000000..a6032a7f72 --- /dev/null +++ b/core/tests/Fixtures/shared-type-root-request.php @@ -0,0 +1,302 @@ + evolution -> evo-3.6.2 sits above + * the site; assets/images links to ../../shared/assets/images; assets/plugins/myplugin links (by + * an absolute path) to a checkout elsewhere, whose images are linked into the shared image folder. + * + * argv: base64 JSON {base: canonical|linked, upload: site|shared} + * Prints JSON with both managers' answers. + */ + +$options = json_decode(base64_decode($argv[1] ?? ''), true) ?: []; +$home = sys_get_temp_dir() . '/evo-shared-root-' . bin2hex(random_bytes(6)); +$site = $home . '/evo-3.6.2'; +$shared = $home . '/shared/assets/images'; +$repo = $home . '/repos/myplugin'; +foreach ([$site . '/assets/files/team', $site . '/assets/plugins', $shared . '/private', $shared . '/public', + $repo . '/images', $home . '/outside'] as $dir) { + mkdir($dir, 0777, true); +} +file_put_contents($shared . '/private/secret.png', 'secret'); +file_put_contents($shared . '/public/open.png', 'open'); +file_put_contents($repo . '/images/logo.png', 'logo'); +file_put_contents($repo . '/plugin.php', ' 'symlinks are not available']); + return; +} +symlink($repo, $site . '/assets/plugins/myplugin'); // absolute: a plugin checked out elsewhere +symlink('../../../repos/myplugin/images', $shared . '/plugin-images'); // relative: its images, for the browser +symlink($home . '/outside', $shared . '/outside'); // somewhere new: passed +symlink($shared . '/private', $shared . '/internal'); // a second name for private: not passed +symlink($site . '/assets/files/team', $shared . '/team'); // a second name for a site folder: not passed +symlink('..', $shared . '/public/loop'); // a loop: not passed +symlink($home . '/missing', $shared . '/dangling'); // leads nowhere: not passed + +$basePath = ($options['base'] ?? 'canonical') === 'linked' ? $home . '/public_html/' : $site . '/'; +$uploadDir = ($options['upload'] ?? 'site') === 'shared' ? $home . '/shared/assets/' : '[(base_path)]assets/'; +$mcpuk = dirname(__DIR__, 3) . '/manager/media/browser/mcpuk'; + +define('EVO_BASE_PATH', $basePath); +define('EVO_BASE_URL', '/'); +define('EVO_SITE_URL', 'https://example.test/'); +define('EVO_MANAGER_PATH', $basePath . 'manager/'); +define('EVO_CORE_PATH', $basePath . 'core/'); +define('IN_MANAGER_MODE', true); +define('IN_INSTALL_MODE', false); +define('EVO_API_MODE', false); + +/** + * The CMS object the browser is constructed with: settings of the site above, nothing else. + */ +class DocumentParser +{ + public array $configGlobal = []; + public array $config; + public array $log = []; + public array $denied = []; + + public function __construct(string $uploadDir) + { + $this->config = [ + 'filemanager_path' => '[(base_path)]', + 'rb_base_dir' => $uploadDir, + 'rb_base_url' => 'assets/', + 'use_udperms' => 1, + 'upload_images' => 'png,jpg', + 'upload_files' => 'txt,pdf', + 'upload_media' => 'mp4', + 'upload_maxsize' => 1048576, + 'new_folder_permissions' => '0777', + 'new_file_permissions' => '0666', + 'thumbsDir' => '.thumbs', + 'thumbWidth' => 100, + 'thumbHeight' => 100, + 'jpegQuality' => 90, + ]; + } + + public function getConfig($key, $default = null) + { + $value = $this->config[$key] ?? $default; + + return is_string($value) ? str_replace('[(base_path)]', EVO_BASE_PATH, $value) : $value; + } + + public function hasPermission($permission) + { + return !in_array($permission, $this->denied, true); + } + + public function invokeEvent(...$args) + { + return []; + } + + public function logEvent($id, $type, $message, $source) + { + $this->log[] = strip_tags($message); + } + + public function toDateFormat($timestamp, $mode = '') + { + return date('Y-m-d H:i', (int) $timestamp); + } +} + +/** Lexicon and style entries ls() prints; any key reads as its own name. */ +final class FixtureLexicon implements ArrayAccess +{ + public function offsetExists($offset): bool + { + return true; + } + + public function offsetGet($offset): mixed + { + return (string) $offset; + } + + public function offsetSet($offset, $value): void + { + } + + public function offsetUnset($offset): void + { + } +} + +final class ManagerTheme +{ + public static function getLexicon() + { + return new FixtureLexicon(); + } + + public static function getStyle() + { + return new FixtureLexicon(); + } +} + +$GLOBALS['evo'] = new DocumentParser($uploadDir); +function evo() +{ + return $GLOBALS['evo']; +} +function evolutionCMS() +{ + return evo(); +} + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; +spl_autoload_register(static function ($class) use ($mcpuk) { + $map = [ + 'browser' => 'core/browser.php', 'uploader' => 'core/uploader.php', 'dir' => 'lib/helper_dir.php', + 'file' => 'lib/helper_file.php', 'path' => 'lib/helper_path.php', 'text' => 'lib/helper_text.php', + 'input' => 'lib/class_input.php', 'image' => 'lib/class_image.php', 'image_gd' => 'lib/class_image_gd.php', + 'image_imagick' => 'lib/class_image_imagick.php', 'image_gmagick' => 'lib/class_image_gmagick.php', + 'gd' => 'lib/class_gd.php', 'type_img' => 'core/types/type_img.php', 'type_mime' => 'core/types/type_mime.php', + ]; + if (isset($map[$class])) { + require $mcpuk . '/' . $map[$class]; + } +}); + +$capsule = new Illuminate\Database\Capsule\Manager(); +$capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); +$capsule->getConnection()->statement('CREATE TABLE file_groups (id INTEGER PRIMARY KEY, document_group INTEGER, file TEXT)'); +$capsule->getConnection()->table('file_groups')->insert([ + ['id' => 1, 'document_group' => 7, 'file' => 'assets/images/private'], + ['id' => 2, 'document_group' => 7, 'file' => 'assets/files/team'], +]); + +try { + // what Apache hands browse.php on such hosting: the document root by its public spelling, + // the working directory by its real one + $_SERVER = array_merge($_SERVER, [ + 'DOCUMENT_ROOT' => $home . '/public_html', + 'SCRIPT_NAME' => '/manager/media/browser/mcpuk/browse.php', + 'PHP_SELF' => '/manager/media/browser/mcpuk/browse.php', + 'SCRIPT_FILENAME' => $mcpuk . '/browse.php', + 'HTTP_HOST' => 'example.test', + 'REQUEST_METHOD' => 'GET', + ]); + $_GET = ['type' => 'images']; + $_POST = []; + $_COOKIE = []; + $_SESSION = ['mgrRole' => 2, 'mgrDocgroups' => []]; + chdir($mcpuk); + $browser = new browser(evo()); + $invoke = static fn ($name, ...$args) => (new ReflectionMethod(browser::class, $name))->invoke($browser, ...$args); + $typeDir = $browser->typeDir; + $secret = $typeDir . '/private/secret.png'; + $open = $typeDir . '/public/open.png'; + $pluginImage = $typeDir . '/plugin-images/logo.png'; + + $output = [ + 'typeURL' => $browser->typeURL, + 'browserKey' => $invoke('getFileGroupsRelPath', $secret), + 'browserOutsiderReadsSecret' => $invoke('isPathAccessible', $secret), + 'browserListing' => array_map('basename', $invoke('filterAccessiblePaths', array_map( + static fn ($name) => $typeDir . '/' . $name, + ['private', 'public', 'plugin-images', 'outside', 'internal', 'team', 'dangling'] + ))), + 'browserPluginImage' => $invoke('isPathAccessible', $pluginImage), + 'browserPluginImageKey' => $invoke('getFileGroupsRelPath', $pluginImage), + 'browserPluginUpload' => $invoke('isWriteAllowed', 'plugin-images'), + 'browserSecondName' => $invoke('isPathAccessible', $typeDir . '/internal/secret.png'), + 'browserSiteName' => $invoke('isPathAccessible', $typeDir . '/team'), + 'browserLoop' => $invoke('isPathAccessible', $typeDir . '/public/loop/private/secret.png'), + 'browserDangling' => $invoke('isPathAccessible', $typeDir . '/dangling'), + 'browserClimb' => $invoke('isPathAccessible', $typeDir . '/outside/../private/secret.png'), + 'browserInertLinks' => array_column($invoke('getLinks', 'images'), 'name'), + ]; + + // the classic file manager, as files.dynamic.php sets it up + $root = rtrim(str_replace('\\', '/', fileManagerCanonicalPath(evo()->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH)), '/'); + $relative = static fn ($request) => fileManagerResolvePath($root, $request)['relative'] ?? null; + $resolved = fileManagerResolvePath($root, 'assets/images/private/secret.png'); + $output += [ + 'sharedRoots' => array_map( + static fn ($link) => substr($link, strlen($root) + 1), + array_keys(fileManagerSharedTypeRoots()) + ), + 'classicKey' => $resolved === null ? null : fileManagerAclKey($resolved['relative']), + 'classicOutsiderReadsSecret' => $resolved !== null && fileManagerIsAccessible($resolved['relative'], []), + 'classicMemberReadsSecret' => $resolved !== null && fileManagerIsAccessible($resolved['relative'], [7]), + 'classicPluginImage' => $relative('assets/images/plugin-images/logo.png'), + 'classicPluginFolder' => $relative('assets/plugins/myplugin/images/logo.png'), + 'classicOutside' => $relative('assets/images/outside/loot.txt'), + 'classicSecondName' => $relative('assets/images/internal/secret.png'), + 'classicSiteName' => $relative('assets/images/team'), + 'classicLoop' => $relative('assets/images/public/loop/private'), + 'classicDangling' => $relative('assets/images/dangling'), + 'classicClimb' => $relative('assets/images/../files'), + 'classicClimbOutOfPlugin' => $relative('assets/plugins/myplugin/../../files'), + 'writeInside' => fileManagerIsNewWriteTarget($root, $root . '/assets/images/public/new.png'), + 'writeIntoPlugin' => fileManagerIsNewWriteTarget($root, $root . '/assets/plugins/myplugin/images/new.png'), + 'writeOverLink' => fileManagerIsSafeWriteTarget($root, $root . '/assets/images'), + 'writeThroughSecondName' => fileManagerIsSafeWriteTarget($root, $root . '/assets/images/internal/new.png'), + 'isSharedRoot' => fileManagerSharedTypeRootAt($root . '/assets/images') !== null, + 'isSharedRootPlugin' => fileManagerSharedTypeRootAt($root . '/assets/plugins/myplugin') !== null, + 'pluginCode' => fileManagerCodeEntry($root . '/assets/plugins/myplugin'), + 'pluginCodeFromAssets' => fileManagerCodeEntry($root . '/assets/plugins'), + ]; + evo()->denied = ['save_plugin', 'save_snippet', 'save_module']; + $output['pluginProtected'] = fileManagerPathIsProtected($root . '/assets/plugins/myplugin/images', fileManagerProtectedPaths()); + evo()->denied = []; + + // the listing of assets/images: linked folders are folders, the rest stay inert links + $listingOf = static function ($path) use ($root) { + ob_start(); + ls($root . '/' . $path, [ + 'len' => 0, 'editablefiles' => [], 'enablefileunzip' => false, 'inlineviewablefiles' => [], + 'uploadablefiles' => [], 'enablefiledownload' => true, 'viewablefiles' => [], 'protected_path' => [], + 'excludes' => [], 'filemanager_path' => $root, 'base_path' => rtrim(str_replace('\\', '/', realpath(EVO_BASE_PATH)), '/'), + 'showFileGroups' => true, 'fileGroupsMap' => [], 'allDocGroups' => [], 'userGroups' => [], + ]); + + return ob_get_clean(); + }; + $assets = $listingOf('assets'); + $images = $listingOf('assets/images'); + $output += [ + 'imagesListedAsFolder' => strpos($assets, 'mode=drill&path=assets%2Fimages"') !== false, + 'imagesOffersDelete' => strpos($assets, 'deleteFolder(\'images\'') !== false, + 'imagesOffersRename' => strpos($assets, 'renameFolder(\'images\')') !== false, + 'pluginImagesListedAsFolder' => strpos($images, 'mode=drill&path=assets%2Fimages%2Fplugin-images"') !== false, + 'pluginImagesOffersDelete' => strpos($images, 'deleteFolder(\'plugin-images\'') !== false, + 'inertListedAsLinks' => array_values(array_filter( + ['internal', 'team', 'dangling', 'outside'], + static fn ($name) => strpos($images, 'deleteLink(\'' . $name . '\')') !== false + )), + ]; + + // deleting a linked folder removes the link, never what it leads to + $output['removedLink'] = rrmdir($root . '/assets/plugins/myplugin'); + $output['linkGone'] = !file_exists($site . '/assets/plugins/myplugin') && !is_link($site . '/assets/plugins/myplugin'); + $output['checkoutKept'] = is_file($repo . '/plugin.php') && is_file($repo . '/images/logo.png'); + $output['log'] = evo()->log; + echo json_encode($output, JSON_UNESCAPED_SLASHES); +} finally { + chdir(__DIR__); + $items = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($home, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($items as $item) { + if ($item->isLink() || !$item->isDir()) { + unlink($item->getPathname()); + } else { + rmdir($item->getPathname()); + } + } + rmdir($home); +} diff --git a/core/tests/Unit/Security/FileManagerAclBypassSourceTest.php b/core/tests/Unit/Security/FileManagerAclBypassSourceTest.php index f3fe5ac855..040d210697 100644 --- a/core/tests/Unit/Security/FileManagerAclBypassSourceTest.php +++ b/core/tests/Unit/Security/FileManagerAclBypassSourceTest.php @@ -170,14 +170,16 @@ function fmAclSource(string $relative): string ->and($browser)->toContain("'xb'"); }); -it('does not list links of the classic file manager nor serve a thumbnail through one', function () { +it('lists only passable links of the classic file manager as entries, nor serves a thumbnail through another', function () { $functions = fmAclSource('core/functions/actions/files.php'); $browser = fmAclSource('manager/media/browser/mcpuk/core/browser.php'); - $ls = substr($functions, strpos($functions, '// first, get info'), 700); + $ls = substr($functions, strpos($functions, '// first, get info'), 2000); $thumb = substr($browser, strpos($browser, 'function act_thumb'), 1500); - expect($ls)->toContain('if (fileManagerIsLink($newpath)) {') - ->and(strpos($ls, 'fileManagerIsLink($newpath)'))->toBeLessThan(strpos($ls, 'fileManagerIsAccessible(')) + // a link the file managers do not pass is set aside before any ACL lookup by its name + expect($ls)->toContain('$passableLink = $isLink && !fileManagerPathContainsLink($filemanager_path, $newpath);') + ->toContain('if ($isLink && !$passableLink) {') + ->and(strpos($ls, 'if ($isLink && !$passableLink) {'))->toBeLessThan(strpos($ls, 'fileManagerIsAccessible(')) ->and($thumb)->toContain('!$this->isSafeThumbPath($file)') ->and(strpos($thumb, 'isSafeThumbPath($file)'))->toBeLessThan(strpos($thumb, 'is_file($file)')); }); diff --git a/core/tests/Unit/Security/FileManagerCodeRemovalTest.php b/core/tests/Unit/Security/FileManagerCodeRemovalTest.php index eaa177d422..80f890c28c 100644 --- a/core/tests/Unit/Security/FileManagerCodeRemovalTest.php +++ b/core/tests/Unit/Security/FileManagerCodeRemovalTest.php @@ -65,19 +65,21 @@ function removeCodeRemovalTree(string $dir): void } }); -it('counts a link by its own name and never follows one', function () { +it('counts a link it does not pass by its own name and never follows it', function () { $base = codeRemovalTree(); try { + // a loop (it leads to a folder around it), which the file managers never pass; a link + // they pass is a folder whose code counts (shared-type-root-request.php fixture) $link = "$base/clean/to-outside"; - if (!@symlink("$base/outside", $link) && PHP_OS_FAMILY === 'Windows') { + if (!@symlink($base, $link) && PHP_OS_FAMILY === 'Windows') { // without the symlink privilege a junction does the same evoRunCommand('mklink /J ' . escapeshellarg(str_replace('/', '\\', $link)) . ' ' - . escapeshellarg(str_replace('/', '\\', "$base/outside")) . ' 2>&1'); + . escapeshellarg(str_replace('/', '\\', $base)) . ' 2>&1'); } if (!fileManagerIsLink($link)) { $this->markTestSkipped('links cannot be created here'); } - // the folder it leads to holds shell.php, but the link is not followed + // the folder it leads to holds outside/shell.php, but the link is not followed expect(fileManagerCodeEntry("$base/clean"))->toBeNull() ->and(fileManagerCodeEntry("$base/clean/to-outside"))->toBeNull(); diff --git a/core/tests/Unit/Security/FileManagerLinkDisplayTest.php b/core/tests/Unit/Security/FileManagerLinkDisplayTest.php index 03618d65a1..ad3f96a16d 100644 --- a/core/tests/Unit/Security/FileManagerLinkDisplayTest.php +++ b/core/tests/Unit/Security/FileManagerLinkDisplayTest.php @@ -167,12 +167,13 @@ function linkDisplayRemove(string $path): void ->not->toHaveKey('app/current/index.txt'); })->skip(!class_exists('ZipArchive'), 'zip is required'); -it('lists links in both managers without a URL, an action or a selection', function () { +it('lists links it does not pass in both managers without a URL, an action or a selection', function () { $root = dirname(__DIR__, 4); $functions = (string) file_get_contents($root . '/core/functions/actions/files.php'); $browser = (string) file_get_contents($root . '/manager/media/browser/mcpuk/core/browser.php'); $files = (string) file_get_contents($root . '/manager/media/browser/mcpuk/js/browser/files.js'); - $start = strpos($functions, 'if (fileManagerIsLink($newpath)) {'); + // the links the file managers do not pass (a passable one is listed as a folder or file) + $start = strpos($functions, 'if ($isLink && !$passableLink) {'); $branch = substr($functions, $start, strpos($functions, 'continue;', $start) - $start); $linksHtml = substr($files, strpos($files, 'browser.linksHtml = function'), 1600); diff --git a/core/tests/Unit/Security/FileManagerPathContainmentTest.php b/core/tests/Unit/Security/FileManagerPathContainmentTest.php index a674f5b5cd..4504edfb94 100644 --- a/core/tests/Unit/Security/FileManagerPathContainmentTest.php +++ b/core/tests/Unit/Security/FileManagerPathContainmentTest.php @@ -86,12 +86,15 @@ function fmContainmentZip(string $path, array $entries): void ->and(fileManagerIsSafeWriteTarget($root, $this->tmp . '/root-old/file.txt'))->toBeFalse(); }); -it('rejects write targets that pass through a symlinked folder', function () { - mkdir($this->tmp . '/root', 0777, true); +it('rejects write targets that pass through a link the file managers do not pass', function () { + mkdir($this->tmp . '/root/inside', 0777, true); mkdir($this->tmp . '/outside', 0777, true); - fmContainmentSymlink($this->tmp . '/outside', $this->tmp . '/root/link'); + // a loop (around the folder it sits in) and a second name for a folder below the root + fmContainmentSymlink($this->tmp, $this->tmp . '/root/link'); + fmContainmentSymlink($this->tmp . '/root/inside', $this->tmp . '/root/alias'); - expect(fileManagerIsSafeWriteTarget($this->tmp . '/root', $this->tmp . '/root/link/new/file.txt'))->toBeFalse(); + expect(fileManagerIsSafeWriteTarget($this->tmp . '/root', $this->tmp . '/root/link/outside/file.txt'))->toBeFalse() + ->and(fileManagerIsSafeWriteTarget($this->tmp . '/root', $this->tmp . '/root/alias/file.txt'))->toBeFalse(); }); it('rejects a write target that is itself a symlink', function () { @@ -211,20 +214,21 @@ function fmContainmentZip(string $path, array $entries): void ->and(file_get_contents($this->tmp . '/dest/images/ok.png'))->toBe('png'); }); -it('does not follow a symlinked folder or file while extracting', function () { +it('does not follow a link it does not pass, nor write over a linked file, while extracting', function () { mkdir($this->tmp . '/dest', 0777, true); mkdir($this->tmp . '/outside', 0777, true); file_put_contents($this->tmp . '/victim.txt', 'original'); - fmContainmentSymlink($this->tmp . '/outside', $this->tmp . '/dest/link'); + // a loop: it leads to a folder around the one it sits in + fmContainmentSymlink($this->tmp, $this->tmp . '/dest/link'); fmContainmentSymlink($this->tmp . '/victim.txt', $this->tmp . '/dest/file.txt'); fmContainmentZip($this->tmp . '/a.zip', [ - 'link/new/escaped.txt' => 'x', + 'link/outside/escaped.txt' => 'x', 'file.txt' => 'overwritten', ]); fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/dest'); - expect(file_exists($this->tmp . '/outside/new/escaped.txt'))->toBeFalse() + expect(file_exists($this->tmp . '/outside/escaped.txt'))->toBeFalse() ->and(file_get_contents($this->tmp . '/victim.txt'))->toBe('original'); }); diff --git a/core/tests/Unit/Security/FileManagerSymlinkWritesTest.php b/core/tests/Unit/Security/FileManagerSymlinkWritesTest.php index b843b52f77..713f765337 100644 --- a/core/tests/Unit/Security/FileManagerSymlinkWritesTest.php +++ b/core/tests/Unit/Security/FileManagerSymlinkWritesTest.php @@ -201,7 +201,8 @@ function fmLinksBrowser(string $typeDir): browser it('accepts only a new name inside the type folder as an upload target', function () { fmLinksSymlink($this->tmp . '/outside/shell.php', $this->root . '/dangling.png'); - fmLinksSymlink($this->tmp . '/outside', $this->root . '/linked'); + // a loop, which the file managers never pass (a link they pass is a folder of the type) + fmLinksSymlink($this->tmp, $this->root . '/linked'); $browser = fmLinksBrowser($this->root); $method = new ReflectionMethod(browser::class, 'isNewUploadTarget'); diff --git a/core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php b/core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php index 548263e75a..2d3af496da 100644 --- a/core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php +++ b/core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php @@ -82,16 +82,19 @@ function mediaBrowserSymlink(string $target, string $link): void ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/../outside/secret.txt'))->toBeFalse(); }); -it('rejects a symlinked folder that points outside the type folder', function () { - mediaBrowserSymlink($this->tmp . '/outside', $this->tmp . '/images/link'); +// A link to somewhere new is a folder of the type, named by its own path: see the +// shared-type-root-request.php fixture, which runs with the CMS settings that decision needs. +it('rejects a symlinked folder that loops back around the type folder', function () { + mediaBrowserSymlink($this->tmp, $this->tmp . '/images/link'); expect(mediaBrowserIsInside($this->browser, $this->tmp . '/images/link'))->toBeFalse() - ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/link/secret.txt'))->toBeFalse() - ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/link/new.png'))->toBeFalse(); + ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/link/outside/secret.txt'))->toBeFalse() + ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/link/outside/new.png'))->toBeFalse(); }); -it('rejects a symlinked file and a dangling symlink', function () { - mediaBrowserSymlink($this->tmp . '/outside/secret.txt', $this->tmp . '/images/secret.txt'); +it('rejects a symlinked file that names a file a second time, and a dangling symlink', function () { + file_put_contents($this->tmp . '/images/team/secret.txt', 'secret'); + mediaBrowserSymlink($this->tmp . '/images/team/secret.txt', $this->tmp . '/images/secret.txt'); mediaBrowserSymlink($this->tmp . '/outside/missing.txt', $this->tmp . '/images/dangling'); expect(mediaBrowserIsInside($this->browser, $this->tmp . '/images/secret.txt'))->toBeFalse() diff --git a/core/tests/Unit/Security/SharedBrowserAccessTest.php b/core/tests/Unit/Security/SharedBrowserAccessTest.php index fdf1cf82f0..6a55455b38 100644 --- a/core/tests/Unit/Security/SharedBrowserAccessTest.php +++ b/core/tests/Unit/Security/SharedBrowserAccessTest.php @@ -60,15 +60,19 @@ function sharedBrowserResult(): array ->and(implode('\n', $result['log']))->toContain('file groups root cannot be resolved'); }); -it('keeps symlinks and traversal refused including for administrators', function () { +it('passes links to somewhere new, and keeps second names, dangling links and traversal refused', function () { $result = sharedBrowserResult(); - foreach (['escape', 'internal', 'dangling', 'traversal', 'adminEscape'] as $key) { + // internal names the private folder a second time, which would bypass its file groups + foreach (['internal', 'dangling', 'traversal'] as $key) { expect($result[$key])->toBeFalse($key); } - expect($result['adminRead'])->toBeTrue(); - expect($result['visibleLink'])->toBeTrue(); // inert link entry; opening it is still refused + // escape leads somewhere new: a folder of the type, named by its own path + expect($result['escape'])->toBeTrue() + ->and($result['adminEscape'])->toBeTrue() + ->and($result['adminRead'])->toBeTrue() + ->and($result['visibleLink'])->toBeTrue(); expect($result['safeThumbs'])->toBeTrue() - ->and($result['linkedThumbs'])->toBeFalse(); + ->and($result['linkedThumbs'])->toBeTrue(); }); it('accepts a proven shared root but reports missing and unmapped roots', function () { diff --git a/core/tests/Unit/Security/SharedTypeRootRequestTest.php b/core/tests/Unit/Security/SharedTypeRootRequestTest.php new file mode 100644 index 0000000000..685185caa5 --- /dev/null +++ b/core/tests/Unit/Security/SharedTypeRootRequestTest.php @@ -0,0 +1,111 @@ + $base, 'upload' => $upload])), + ], $exitCode); + $results[$key] = json_decode($output, true); + expect($exitCode)->toBe(0, $output) + ->and($results[$key])->toBeArray($output); + } + if (isset($results[$key]['skip'])) { + test()->markTestSkipped($results[$key]['skip']); + } + + return $results[$key]; +} + +dataset('site layouts', [ + 'site root by its real path, upload folder in the site' => ['canonical', 'site'], + 'site root through public_html -> evolution -> release' => ['linked', 'site'], + 'upload folder configured in shared storage' => ['linked', 'shared'], +]); + +it('keys a shared image folder the same way in both managers, from the configured URL', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + // what KCFinder makes of rb_base_url "assets/" here: not the folder, so nothing may rely on it + expect($result['typeURL'])->not->toBe('/assets/images') + ->and($result['sharedRoots'])->toBe(['assets/images']) + ->and($result['browserKey'])->toBe('assets/images/private/secret.png') + ->and($result['classicKey'])->toBe('assets/images/private/secret.png') + ->and($result['browserOutsiderReadsSecret'])->toBeFalse() + ->and($result['classicOutsiderReadsSecret'])->toBeFalse() + ->and($result['classicMemberReadsSecret'])->toBeTrue(); +})->with('site layouts'); + +it('passes links to somewhere new as folders named by their own path', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + expect($result['browserListing'])->toBe(['public', 'plugin-images', 'outside']) + ->and($result['browserPluginImage'])->toBeTrue() + ->and($result['browserPluginImageKey'])->toBe('assets/images/plugin-images/logo.png') + ->and($result['browserPluginUpload'])->toBeTrue() + ->and($result['classicPluginImage'])->toBe('assets/images/plugin-images/logo.png') + ->and($result['classicPluginFolder'])->toBe('assets/plugins/myplugin/images/logo.png') + ->and($result['classicOutside'])->toBe('assets/images/outside/loot.txt') + ->and($result['writeInside'])->toBeTrue() + ->and($result['writeIntoPlugin'])->toBeTrue(); +})->with('site layouts'); + +it('refuses second names, loops, dangling links and climbing out of a link', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + foreach (['browserSecondName', 'browserSiteName', 'browserLoop', 'browserDangling', 'browserClimb'] as $key) { + expect($result[$key])->toBeFalse($key); + } + foreach (['classicSecondName', 'classicSiteName', 'classicLoop', 'classicDangling', 'classicClimb', 'classicClimbOutOfPlugin'] as $key) { + expect($result[$key])->toBeNull($key); + } + expect($result['writeOverLink'])->toBeFalse() + ->and($result['writeThroughSecondName'])->toBeFalse() + ->and($result['browserInertLinks'])->toBe(['dangling', 'internal', 'team']) + ->and($result['inertListedAsLinks'])->toBe(['internal', 'team', 'dangling']); +})->with('site layouts'); + +it('applies the rules of the place a link sits in, code included', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + // a plugin checked out elsewhere stays behind save_plugin, and its code counts as code + expect($result['pluginProtected'])->toBeTrue() + ->and($result['pluginCode'])->toBe('plugin.php') + ->and($result['pluginCodeFromAssets'])->toBe('myplugin/plugin.php'); +})->with('site layouts'); + +it('lists the shared image folder as a folder that is entered but never removed or renamed', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + expect($result['isSharedRoot'])->toBeTrue() + ->and($result['isSharedRootPlugin'])->toBeFalse() + ->and($result['imagesListedAsFolder'])->toBeTrue() + ->and($result['imagesOffersDelete'])->toBeFalse() + ->and($result['imagesOffersRename'])->toBeFalse() + ->and($result['pluginImagesListedAsFolder'])->toBeTrue() + ->and($result['pluginImagesOffersDelete'])->toBeTrue(); +})->with('site layouts'); + +it('removes a linked folder as a link, never what it leads to', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + expect($result['removedLink'])->toBeTrue() + ->and($result['linkGone'])->toBeTrue() + ->and($result['checkoutKept'])->toBeTrue() + ->and($result['log'])->toBe([]); +})->with('site layouts'); + +it('refuses deleting, renaming or unlinking the shared type folder in the classic manager', function () { + $source = (string) file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php'); + + expect(substr_count($source, 'fileManagerSharedTypeRootAt($folder) !== null'))->toBe(1) + ->and(substr_count($source, 'fileManagerSharedTypeRootAt($dirname) !== null'))->toBe(1) + ->and(substr_count($source, 'fileManagerSharedTypeRootAt($linkPath) !== null'))->toBe(1); +}); diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php index c85f79dac6..04f59147da 100755 --- a/manager/actions/files.dynamic.php +++ b/manager/actions/files.dynamic.php @@ -40,7 +40,7 @@ $uploadablefiles = add_dot($uploadablefiles); $upload_maxsize = evo()->getConfig('upload_maxsize'); $filemanager_path = rtrim(str_replace('\\', '/', - realpath(evo()->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH)), '/'); + fileManagerCanonicalPath(evo()->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH)), '/'); $base_path = rtrim(str_replace('\\', '/', realpath(EVO_BASE_PATH)), '/'); // end settings // get the current work directory @@ -50,7 +50,7 @@ $selFile = ''; if (is_file($fullpath)) { $selFile = $requested_path; - $startpath = rtrim(str_replace('\\', '/', realpath(dirname($fullpath))), '/'); + $startpath = rtrim((string) fileManagerCanonicalPath(dirname($fullpath)), '/'); } elseif (is_dir($fullpath)) { $startpath = $fullpath; } else { @@ -533,9 +533,11 @@ function renameFile(file) { if ($token_check) { $folderTarget = fileManagerResolvePath($filemanager_path, $_REQUEST['folderpath'] ?? ''); $folder = $folderTarget['path'] ?? ''; + $wasLink = $folder !== '' && fileManagerIsLink($folder); if ($folderTarget === null || !is_dir($folder)) { echo 'Invalid path.

'; } elseif (!fileManagerCanModifyExistingPath($folderTarget['relative'], $userGroups) + || fileManagerSharedTypeRootAt($folder) !== null || fileManagerPathTouchesProtected($folder, $protected_path) || fileManagerHasInaccessibleDescendants($folderTarget['relative']) || !is_writable($folder)) { @@ -547,7 +549,10 @@ function renameFile(file) { echo '' . $_lang['file_folder_not_deleted'] . '

'; } else { echo '' . $_lang['file_folder_deleted'] . '

'; - \EvolutionCMS\Support\FileManagerAccess::forgetRestrictions(fileManagerAclKey($folderTarget['relative'])); + // a linked folder goes as a link: its content and groups stay for the link's return + if (!$wasLink) { + \EvolutionCMS\Support\FileManagerAccess::forgetRestrictions(fileManagerAclKey($folderTarget['relative'])); + } } } else { echo $tokenRefusedMessage; @@ -732,6 +737,8 @@ function renameFile(file) { } elseif (!fileManagerCanModifyExistingPath($linkRelative, $userGroups) || fileManagerPathIsProtected($linkPath, $protected_path)) { echo fileManagerDenied('file group, top level or protected folder'); + } elseif (fileManagerSharedTypeRootAt($linkPath) !== null) { + echo fileManagerDenied('shared type folder'); } elseif (($denied = fileManagerCodeRemovalDenied($linkPath, $linkRelative)) !== null) { // a link named .htaccess is read by the web server like the file echo $denied; @@ -754,6 +761,7 @@ function renameFile(file) { if ($dirTarget === null || !is_dir($dirname)) { echo 'Invalid path.

'; } elseif (!fileManagerCanModifyExistingPath($dirTarget['relative'], $userGroups) + || fileManagerSharedTypeRootAt($dirname) !== null || fileManagerPathTouchesProtected($dirname, $protected_path) || !is_writable($dirname)) { echo fileManagerDenied(); diff --git a/manager/media/browser/mcpuk/core/browser.php b/manager/media/browser/mcpuk/core/browser.php index abf5dd2f5e..2aa89b671f 100755 --- a/manager/media/browser/mcpuk/core/browser.php +++ b/manager/media/browser/mcpuk/core/browser.php @@ -1423,9 +1423,10 @@ protected function getFiles($dir) } /** - * The links in a folder, shown but never followed: they get no URL, thumbnail or action, and - * their target only as a path relative to the link, inside the type folder and reachable by - * the current manager (linkTarget()). + * The links in a folder that the file managers do not pass (fileManagerPassableLinkTarget()), + * shown but never followed: they get no URL, thumbnail or action, and their target only as a + * path relative to the link, inside the type folder and reachable by the current manager + * (linkTarget()). A passable link is listed with the folders and files. * * @param string $dir path below the upload folder, type included * @return array @@ -1446,7 +1447,8 @@ protected function getLinks($dir) continue; } $path = "$dir/$name"; - if (!fileManagerIsLink($path) || !$this->isLinkVisible($path)) { + // a link the file managers pass is listed as the folder or file it stands for + if (!fileManagerIsLink($path) || $this->isInsideTypeDir($path) || !$this->isLinkVisible($path)) { continue; } $stat = @lstat($path); @@ -1767,8 +1769,9 @@ protected function getDir($existent = true) /** * Whether $absPath, once symlinks are resolved, is still inside the current type folder. - * The dir and file parameters are only checked by name; a symlink below the upload folder - * would otherwise lead every action (and the file groups check) outside it. + * The dir and file parameters are only checked by name. A link below the upload folder is + * passed only when the file managers pass it (fileManagerPathContainsLink()): then it is a + * folder of the type, named by its own path, which the file groups are keyed by. * * @param string $absPath * @return bool @@ -1802,28 +1805,25 @@ protected function isInProtectedFolder($absPath) */ protected function isInsideTypeRoot($absPath) { - $root = realpath($this->typeDir); - if ($root === false) { + $realRoot = realpath($this->typeDir); + $path = fileManagerNormalizePathLexically($absPath); + if ($realRoot === false || $path === null) { + return false; + } + // by name: a link below the type folder that the file managers pass (isInsideTypeDir() + // asks fileManagerPathContainsLink() first) is a folder of it, wherever it leads + $path = rtrim($path, '/'); + $within = \EvolutionCMS\Support\FileManagerAccess::isWithin($this->typeDir, $path) + || \EvolutionCMS\Support\FileManagerAccess::isWithin(str_replace('\\', '/', $realRoot), $path); + if (!$within) { return false; } - $path = realpath($absPath); - if ($path === false) { + if (!file_exists($path)) { // nothing there yet is fine, a dangling link is not: writing to it creates its target - if (dir::isLink($absPath)) { - return false; - } - $parent = realpath(dirname($absPath)); - - return $parent !== false && \EvolutionCMS\Support\FileManagerAccess::isWithin( - str_replace('\\', '/', $root), - str_replace('\\', '/', $parent) - ); + return !fileManagerIsLink($path) && realpath(dirname($path)) !== false; } - return \EvolutionCMS\Support\FileManagerAccess::isWithin( - str_replace('\\', '/', $root), - str_replace('\\', '/', $path) - ); + return true; } /** diff --git a/manager/media/browser/mcpuk/lib/helper_dir.php b/manager/media/browser/mcpuk/lib/helper_dir.php index 0085d33d3d..d641096419 100755 --- a/manager/media/browser/mcpuk/lib/helper_dir.php +++ b/manager/media/browser/mcpuk/lib/helper_dir.php @@ -152,6 +152,11 @@ static function content(string $dir, array $options=[]) { } while ($type == "link"); } + // a Windows junction followed: its target's type + if ($options['followLinks'] && !in_array($type, ["dir", "file"], true)) { + $type = is_dir("$dir/$file") ? "dir" : (is_file("$dir/$file") ? "file" : $type); + } + if ((($type === "dir") && (($file == ".") || ($file == ".."))) || !preg_match($options['pattern'], $file) ) From 20a32a0fc3baf3a9acf176ff88d346114cc711ea Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 3 Nov 2026 14:27:34 +0100 Subject: [PATCH 05/50] fix(file-manager): protect folders by real target and never write through a linked file A passed link keeps its own name, which file groups are keyed by, but the protected-folder rule compared only that name. A link to a folder protected outside the ACL root (an external view path; core/, manager/ or views/ under a narrowed filemanager_path) opened it to managers without the permission. fileManagerPathIsProtected() and fileManagerPathTouchesProtected() now also match where a path lands: the real path of its nearest existing ancestor with the rest appended, so new destinations are covered too. Removing a link resolves only the folder it sits in. textsave() checked a linked file by its name while file_put_contents() followed the link, so a manager without code permissions could write PHP into the target through a text name. It now refuses a final link through fileManagerIsSafeWriteTarget(), as the classic upload over an existing name does now too (a move across file systems copies, and the copy follows a link). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- core/functions/actions/files.php | 84 +++++++++++++++++-- .../Fixtures/shared-type-root-request.php | 58 ++++++++++++- .../Security/FileManagerLinkDisplayTest.php | 4 +- .../FileManagerWritePathChecksTest.php | 4 +- .../Security/SharedTypeRootRequestTest.php | 31 +++++++ manager/actions/files.dynamic.php | 2 +- manager/media/browser/mcpuk/core/browser.php | 2 +- 8 files changed, 172 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca19e8c4e7..64cadc01f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -445,8 +445,8 @@ The name check also catches the spellings Windows turns into such a file: traili **Always enforced** - Paths outside `filemanager_path` are rejected, and `..` never climbs above the root. -- Links below a root are the operator's (the CMS never creates one), so a link to somewhere new is a folder (or file) like any other: a shared upload folder, a plugin checked out elsewhere, images linked in from another disk. Absolute or relative, whatever its spelling, it is judged by its real target (`fileManagerPassableLinkTarget()`), and passed when that target exists and is new: not inside or around the ACL root, not around the folder the link sits in (a loop), not inside or around anything the walk has named already (where it started, the links it passed), not inside shared storage that a type-folder link names (`fileManagerSharedTypeRoots()`). Any of those would give a file a second name, and file groups are keyed by name, so a group on `team` would not hold `public/link-to-team`. A passed link is named by its own path, never resolved (`fileManagerCanonicalPath()`, which also puts each part into its stored letter case and accepts any spelling of the root: a hosting `public_html` chain or a release link above it), and every rule applies by that name: the folders of layer 1 (`fileManagerProtectedPaths()` holds each by its real path and by its name, so a protected folder may itself be a link), file groups, the executable-name rule, the type folder of the media browser. Past a link, `..` is refused: it climbs in the link's target, not back where the server walks. Two separate links to one outside folder cannot be told apart from here; the operator should not make them, and a server without `FollowSymLinks` does not serve what the CMS then opens. Tests: `SharedTypeRootRequestTest.php` (a web request through the real browser constructor), `SharedBrowserAccessTest.php`. -- A path that passes through any other link below the root is rejected as a whole (`fileManagerPathContainsLink()`): in the classic manager, uploads, direct edit and delete, and the media browser (`isInsideTypeDir()`); a write never goes to a path that is itself a link, dangling or not (`fileManagerIsSafeWriteTarget()`). Both file managers list such a link as an inert entry: no web URL, drill-down, thumbnail or selection (a link may lead anywhere, and the web server would serve its target unchecked). The one action is removing the link itself (`mode=deletelink`, `act_deleteLink`), judged by the link's own path, never resolved: the top-level and group rules of an existing entry, and no link where a protected folder belongs; the target and its groups stay. Its target is shown only as a path relative to the link, and only when it lies inside the manager's root (the media browser: the type folder) and the manager may reach it (`fileManagerRelativeLinkTarget()`); a `../../` or absolute target outside, or a restricted one, is shown as "outside the file manager or not accessible", never as a path. Links are never created: a link entry in a ZIP is not extracted (neither as a link nor as a file holding its path) but reported (`fileManagerExtractZip()` `$links`), a directory archive stores a link as a link entry with that same relative target or leaves it out, and a folder copy leaves it out, passable or not. The media browser serves no thumbnail whose cached path or original runs through a link it does not pass. Nor does it write, rename, copy, move or empty the thumbnail cache through one (`isSafeThumbPath()`, measured from the upload root, so a link at the cache root itself is refused too, before anything is probed or created). +- Links below a root are the operator's (the CMS never creates one), so a link to somewhere new is a folder (or file) like any other: a shared upload folder, a plugin checked out elsewhere, images linked in from another disk. Absolute or relative, whatever its spelling, it is judged by its real target (`fileManagerPassableLinkTarget()`), and passed when that target exists and is new: not inside or around the ACL root, not around the folder the link sits in (a loop), not inside or around anything the walk has named already (where it started, the links it passed), not inside shared storage that a type-folder link names (`fileManagerSharedTypeRoots()`). Any of those would give a file a second name, and file groups are keyed by name, so a group on `team` would not hold `public/link-to-team`. A passed link is named by its own path, never resolved (`fileManagerCanonicalPath()`, which also puts each part into its stored letter case and accepts any spelling of the root: a hosting `public_html` chain or a release link above it), and every rule applies by that name: the folders of layer 1 (`fileManagerProtectedPaths()` holds each by its real path and by its name, so a protected folder may itself be a link), file groups, the executable-name rule, the type folder of the media browser. A protected folder is also matched by where a path lands (`fileManagerPathSpellings()`: the real path of its nearest existing ancestor, the rest appended), so a link to a view path outside the site does not open it under another name; removing a link resolves only the folder it sits in. Past a link, `..` is refused: it climbs in the link's target, not back where the server walks. Two separate links to one outside folder cannot be told apart from here; the operator should not make them, and a server without `FollowSymLinks` does not serve what the CMS then opens. Tests: `SharedTypeRootRequestTest.php` (a web request through the real browser constructor), `SharedBrowserAccessTest.php`. +- A path that passes through any other link below the root is rejected as a whole (`fileManagerPathContainsLink()`): in the classic manager, uploads, direct edit and delete, and the media browser (`isInsideTypeDir()`); a write never goes to a path that is itself a link, dangling or not (`fileManagerIsSafeWriteTarget()`, text saves and uploads over an existing name included): the checks see the link's name, the write would land in its target. Both file managers list such a link as an inert entry: no web URL, drill-down, thumbnail or selection (a link may lead anywhere, and the web server would serve its target unchecked). The one action is removing the link itself (`mode=deletelink`, `act_deleteLink`), judged by the link's own path, never resolved: the top-level and group rules of an existing entry, and no link where a protected folder belongs; the target and its groups stay. Its target is shown only as a path relative to the link, and only when it lies inside the manager's root (the media browser: the type folder) and the manager may reach it (`fileManagerRelativeLinkTarget()`); a `../../` or absolute target outside, or a restricted one, is shown as "outside the file manager or not accessible", never as a path. Links are never created: a link entry in a ZIP is not extracted (neither as a link nor as a file holding its path) but reported (`fileManagerExtractZip()` `$links`), a directory archive stores a link as a link entry with that same relative target or leaves it out, and a folder copy leaves it out, passable or not. The media browser serves no thumbnail whose cached path or original runs through a link it does not pass. Nor does it write, rename, copy, move or empty the thumbnail cache through one (`isSafeThumbPath()`, measured from the upload root, so a link at the cache root itself is refused too, before anything is probed or created). - Path comparison (`FileManagerAccess::isWithin()`) ignores case on Windows, and ZIP entries are resolved to the stored letter case (`fileManagerCanonicalCase()`) before the checks. - The media browser (KCFinder) follows the same rules: it stays out of the folders of layer 1 (`isInProtectedFolder()`), applies the executable-name rule to every name it writes (`validateFilename()`, including the final name of an upload), its temporary download archives carry the `kcf-download-` prefix and are the only `.zip` files it ever cleans up, and a folder rename never replaces an existing destination. diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php index 6207080315..cacf094b17 100644 --- a/core/functions/actions/files.php +++ b/core/functions/actions/files.php @@ -486,16 +486,19 @@ function fileManagerRefuseReservedName($path) if(!function_exists('fileManagerPathIsProtected')) { /** - * Whether $path is a protected folder or lies inside one. + * Whether $path is a protected folder or lies inside one, by its name or where it lands. * * @param string $path canonical absolute path * @param string[] $protectedPaths + * @param bool $followLink false to judge a link by where it sits, not where it leads */ - function fileManagerPathIsProtected($path, array $protectedPaths) + function fileManagerPathIsProtected($path, array $protectedPaths, $followLink = true) { - foreach ($protectedPaths as $protectedPath) { - if (FileManagerAccess::isWithin($protectedPath, $path)) { - return true; + foreach (fileManagerPathSpellings($path, $followLink) as $spelling) { + foreach ($protectedPaths as $protectedPath) { + if (FileManagerAccess::isWithin($protectedPath, $spelling)) { + return true; + } } } @@ -513,9 +516,11 @@ function fileManagerPathIsProtected($path, array $protectedPaths) */ function fileManagerPathTouchesProtected($path, array $protectedPaths) { - foreach ($protectedPaths as $protectedPath) { - if (FileManagerAccess::isWithin($protectedPath, $path) || FileManagerAccess::isWithin($path, $protectedPath)) { - return true; + foreach (fileManagerPathSpellings($path) as $spelling) { + foreach ($protectedPaths as $protectedPath) { + if (FileManagerAccess::isWithin($protectedPath, $spelling) || FileManagerAccess::isWithin($spelling, $protectedPath)) { + return true; + } } } @@ -523,6 +528,59 @@ function fileManagerPathTouchesProtected($path, array $protectedPaths) } } +if(!function_exists('fileManagerPathSpellings')) { + /** + * $path as the file managers name it and as the file system resolves it. A passed link keeps + * its own name (file groups are keyed by it), but a protected folder is protected wherever it + * is reached from: a link to an external view path must not open it under another name. + * + * @param string $path absolute path, which need not exist yet + * @param bool $followLink false to resolve only the folder $path sits in + * @return string[] + */ + function fileManagerPathSpellings($path, $followLink = true) + { + $path = rtrim(str_replace('\\', '/', (string) $path), '/'); + if ($followLink) { + $resolved = fileManagerResolvedPath($path); + } else { + $parent = fileManagerResolvedPath(dirname($path)); + $resolved = $parent === null ? null : $parent . '/' . basename($path); + } + + return $resolved === null || $resolved === $path ? [$path] : [$path, $resolved]; + } +} + +if(!function_exists('fileManagerResolvedPath')) { + /** + * Where $path lands on the file system: the real path of its nearest existing ancestor with + * the rest of $path appended, so a destination that does not exist yet resolves too. Null + * when no part of it exists. + * + * @param string $path absolute path + */ + function fileManagerResolvedPath($path) + { + $current = rtrim(str_replace('\\', '/', (string) $path), '/'); + $tail = ''; + while ($current !== '') { + $real = realpath($current); + if ($real !== false) { + return rtrim(str_replace('\\', '/', $real), '/') . $tail; + } + $parent = dirname($current); + if ($parent === $current || $parent === '.') { + return null; + } + $tail = '/' . basename($current) . $tail; + $current = rtrim(str_replace('\\', '/', $parent), '/'); + } + + return null; + } +} + if(!function_exists('fileManagerIsLink')) { /** * Whether $path is a link of any kind: a symlink, or on Windows a junction or other @@ -2321,7 +2379,11 @@ function fileupload() // The restrictions are keyed by the stored name: on Windows secret.txt is Secret.txt $existingRel = ltrim(substr(fileManagerCanonicalCase($targetFile), strlen($filemanager_path)), '/'); $replacing = file_exists($targetFile); - if ($replacing && !fileManagerCanModifyExistingPath($existingRel)) { + if (!fileManagerIsSafeWriteTarget($filemanager_path, $targetFile)) { + // a move across file systems copies, and the copy follows a link + fileManagerLogDenied('upload over link', $existingRel, 'link'); + $fileMsg .= '

' . $_lang['files_access_denied'] . '

'; + } elseif ($replacing && !fileManagerCanModifyExistingPath($existingRel)) { fileManagerLogDenied('upload over existing file', $existingRel, 'file group'); $fileMsg .= '

' . $_lang['files_access_denied'] . '

'; } elseif (@move_uploaded_file($userfile['tmp_name'], $targetFile)) { @@ -2415,6 +2477,10 @@ function textsave() return 'Invalid path.

'; } $fileRel = ltrim(substr($filename, strlen($filemanager_path)), '/'); + // a linked file is checked by its name, while the write would land in its target + if (!fileManagerIsSafeWriteTarget($filemanager_path, $filename)) { + return fileManagerDenied('link', $fileRel); + } if (!fileManagerCanModifyExistingPath($fileRel) || fileManagerPathIsProtected($filename, fileManagerProtectedPaths()) || (fileManagerIsExecutableName($filename) && !fileManagerMayRunCode()) diff --git a/core/tests/Fixtures/shared-type-root-request.php b/core/tests/Fixtures/shared-type-root-request.php index a6032a7f72..16a56510a8 100644 --- a/core/tests/Fixtures/shared-type-root-request.php +++ b/core/tests/Fixtures/shared-type-root-request.php @@ -6,6 +6,8 @@ * constructor and config.php. The hosting chain public_html -> evolution -> evo-3.6.2 sits above * the site; assets/images links to ../../shared/assets/images; assets/plugins/myplugin links (by * an absolute path) to a checkout elsewhere, whose images are linked into the shared image folder. + * A configured view path lies outside the site and is linked into both managers' folders, and + * assets/files/note.txt links to a PHP file elsewhere. * * argv: base64 JSON {base: canonical|linked, upload: site|shared} * Prints JSON with both managers' answers. @@ -17,7 +19,7 @@ $shared = $home . '/shared/assets/images'; $repo = $home . '/repos/myplugin'; foreach ([$site . '/assets/files/team', $site . '/assets/plugins', $shared . '/private', $shared . '/public', - $repo . '/images', $home . '/outside'] as $dir) { + $repo . '/images', $home . '/outside', $home . '/views', $home . '/notes'] as $dir) { mkdir($dir, 0777, true); } file_put_contents($shared . '/private/secret.png', 'secret'); @@ -25,6 +27,9 @@ file_put_contents($repo . '/images/logo.png', 'logo'); file_put_contents($repo . '/plugin.php', 'denied = ['save_plugin', 'save_snippet', 'save_module']; + (new ReflectionProperty(browser::class, 'protectedPaths'))->setValue($browser, null); + $protected = fileManagerProtectedPaths(); + $textSave = static function ($path, $content) { + $_POST = ['path' => $path, 'content' => $content]; + + return strip_tags(textsave()); + }; + $deleteFile = static function ($path) { + $_REQUEST = ['path' => $path]; + + return strip_tags(delete_file()); + }; + $output += [ + 'saveThroughFileLink' => $textSave('assets/files/note.txt', ' file_get_contents($home . '/notes/run.php'), + 'savePlain' => $textSave('assets/files/plain.txt', 'edited'), + 'plain' => file_get_contents($site . '/assets/files/plain.txt'), + 'saveThroughFolderLink' => $textSave('assets/images/outside/loot.txt', 'edited'), + 'loot' => file_get_contents($home . '/outside/loot.txt'), + 'saveInViewAlias' => $textSave('assets/files/tpl/page.tpl', '{evil}'), + 'deleteInViewAlias' => $deleteFile('assets/files/tpl/page.tpl'), + 'view' => file_get_contents($home . '/views/page.tpl'), + 'createInViewAlias' => fileManagerPathIsProtected($root . '/assets/files/tpl/new.tpl', $protected), + 'createDeepInViewAlias' => fileManagerPathIsProtected($root . '/assets/files/tpl/sub/new.html', $protected), + 'renameInViewAlias' => fileManagerPathTouchesProtected($root . '/assets/files/tpl/page.tpl', $protected), + 'moveIntoViewAlias' => fileManagerPathIsProtected($root . '/assets/files/tpl/moved.txt', $protected), + 'browserViewAlias' => $invoke('isInsideTypeDir', $typeDir . '/views-alias/page.tpl'), + 'browserViewAliasNew' => $invoke('isInsideTypeDir', $typeDir . '/views-alias/new.tpl'), + 'sharedMediaOpen' => !fileManagerPathIsProtected($root . '/assets/images/public/new.png', $protected), + 'browserSharedMedia' => $invoke('isInsideTypeDir', $typeDir . '/public/new.png'), + 'aliasLog' => count(evo()->log), + ]; + evo()->log = []; + evo()->denied = []; + // deleting a linked folder removes the link, never what it leads to $output['removedLink'] = rrmdir($root . '/assets/plugins/myplugin'); $output['linkGone'] = !file_exists($site . '/assets/plugins/myplugin') && !is_link($site . '/assets/plugins/myplugin'); diff --git a/core/tests/Unit/Security/FileManagerLinkDisplayTest.php b/core/tests/Unit/Security/FileManagerLinkDisplayTest.php index ad3f96a16d..3404b24795 100644 --- a/core/tests/Unit/Security/FileManagerLinkDisplayTest.php +++ b/core/tests/Unit/Security/FileManagerLinkDisplayTest.php @@ -225,7 +225,7 @@ function linkDisplayRemove(string $path): void ->toContain("strpbrk(\$linkName, '/\\\\') !== false") ->toContain('fileManagerRefuseReservedName($linkName) || !fileManagerIsLink($linkPath)') ->toContain('fileManagerCanModifyExistingPath($linkRelative, $userGroups)') - ->toContain('fileManagerPathIsProtected($linkPath, $protected_path)') + ->toContain('fileManagerPathIsProtected($linkPath, $protected_path, false)') ->toContain('!fileManagerIsLink($linkPath) || !fileManagerRemoveLink($linkPath)') ->not->toContain('forgetRestrictions') ->not->toContain('rrmdir('); @@ -240,7 +240,7 @@ function linkDisplayRemove(string $path): void ->not->toContain('prune('); // the link's own path: canModifyExisting() would resolve it and judge the target expect($canModify)->toContain('fileManagerCanonicalCase($parent)') - ->toContain('fileManagerPathIsProtected($lexical, $this->protectedPaths)') + ->toContain('fileManagerPathIsProtected($lexical, $this->protectedPaths, false)') ->toContain('fileManagerCanModifyExistingPath(substr($lexical, strlen($root) + 1))') ->not->toContain('realpath($absPath)'); expect($functions)->toContain("'
"); diff --git a/core/tests/Unit/Security/FileManagerWritePathChecksTest.php b/core/tests/Unit/Security/FileManagerWritePathChecksTest.php index 2f9f3ef6e7..9af82ea7b5 100644 --- a/core/tests/Unit/Security/FileManagerWritePathChecksTest.php +++ b/core/tests/Unit/Security/FileManagerWritePathChecksTest.php @@ -260,7 +260,9 @@ function runFileManagerWrite(string $site, string $function, array $request, arr expect($check)->not->toBeFalse() ->and($check)->toBeLessThan(strpos($body, 'move_uploaded_file')) ->and(substr($body, $check - 400, 650))->toContain('fileManagerCanonicalCase($targetFile)') - ->and(substr($body, $check, 250))->toContain('fileManagerCanModifyExistingPath('); + ->and(substr($body, $check, 600))->toContain('fileManagerCanModifyExistingPath(') + // and never over a link, which the move would follow + ->and(substr($body, $check, 250))->toContain('fileManagerIsSafeWriteTarget($filemanager_path, $targetFile)'); }); it('writes a refused save to the event log for the administrator', function () { diff --git a/core/tests/Unit/Security/SharedTypeRootRequestTest.php b/core/tests/Unit/Security/SharedTypeRootRequestTest.php index 685185caa5..aaff8ebbcb 100644 --- a/core/tests/Unit/Security/SharedTypeRootRequestTest.php +++ b/core/tests/Unit/Security/SharedTypeRootRequestTest.php @@ -81,6 +81,37 @@ function sharedTypeRootRequest(string $base, string $upload): array ->and($result['pluginCodeFromAssets'])->toBe('myplugin/plugin.php'); })->with('site layouts'); +it('never writes through a linked file, and edits through a linked folder', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + // note.txt is a text name for a PHP file elsewhere: the name passes the checks, the write would not + expect($result['saveThroughFileLink'])->toBe('files_access_denied') + ->and($result['fileLinkTarget'])->toBe('original') + ->and($result['savePlain'])->toBe('file_saved') + ->and($result['plain'])->toBe('edited') + ->and($result['saveThroughFolderLink'])->toBe('file_saved') + ->and($result['loot'])->toBe('edited'); +})->with('site layouts'); + +it('protects a view path outside the site wherever a link reaches it from', function (string $base, string $upload) { + $result = sharedTypeRootRequest($base, $upload); + + // a template file is executed by its engine whatever the extension + expect($result['saveInViewAlias'])->toBe('files_access_denied') + ->and($result['deleteInViewAlias'])->toBe('files_access_denied') + ->and($result['view'])->toBe('view') + ->and($result['createInViewAlias'])->toBeTrue() + ->and($result['createDeepInViewAlias'])->toBeTrue() + ->and($result['renameInViewAlias'])->toBeTrue() + ->and($result['moveIntoViewAlias'])->toBeTrue() + ->and($result['browserViewAlias'])->toBeFalse() + ->and($result['browserViewAliasNew'])->toBeFalse() + ->and($result['aliasLog'])->toBeGreaterThanOrEqual(3); + // shared media is not a protected folder + expect($result['sharedMediaOpen'])->toBeTrue() + ->and($result['browserSharedMedia'])->toBeTrue(); +})->with('site layouts'); + it('lists the shared image folder as a folder that is entered but never removed or renamed', function (string $base, string $upload) { $result = sharedTypeRootRequest($base, $upload); diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php index 04f59147da..042459690f 100755 --- a/manager/actions/files.dynamic.php +++ b/manager/actions/files.dynamic.php @@ -735,7 +735,7 @@ function renameFile(file) { || fileManagerRefuseReservedName($linkName) || !fileManagerIsLink($linkPath)) { echo 'Invalid path.

'; } elseif (!fileManagerCanModifyExistingPath($linkRelative, $userGroups) - || fileManagerPathIsProtected($linkPath, $protected_path)) { + || fileManagerPathIsProtected($linkPath, $protected_path, false)) { echo fileManagerDenied('file group, top level or protected folder'); } elseif (fileManagerSharedTypeRootAt($linkPath) !== null) { echo fileManagerDenied('shared type folder'); diff --git a/manager/media/browser/mcpuk/core/browser.php b/manager/media/browser/mcpuk/core/browser.php index 2aa89b671f..aea26eb1cd 100755 --- a/manager/media/browser/mcpuk/core/browser.php +++ b/manager/media/browser/mcpuk/core/browser.php @@ -1570,7 +1570,7 @@ protected function canModifyLink($absPath) if ($this->protectedPaths === null) { $this->protectedPaths = fileManagerProtectedPaths(); } - if (fileManagerPathIsProtected($lexical, $this->protectedPaths)) { + if (fileManagerPathIsProtected($lexical, $this->protectedPaths, false)) { return false; } if (!fileManagerAclApplies()) { From b93b8041ee0c25b997af08e2b10a9f789fe0082f Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Mon, 2 Nov 2026 21:18:47 +0200 Subject: [PATCH 06/50] fix(installer): stop CLI input loops on EOF (cherry picked from commit 8e43927f5c4f41b51996ec546416abfa2a410cdf) Co-Authored-By: Claude Opus 5.5 --- core/tests/Fixtures/cli-install-input.php | 42 +++++++ core/tests/standalone/CliInstallInputTest.php | 105 ++++++++++++++++++ install/cli-install.php | 14 +++ 3 files changed, 161 insertions(+) create mode 100644 core/tests/Fixtures/cli-install-input.php create mode 100644 core/tests/standalone/CliInstallInputTest.php diff --git a/core/tests/Fixtures/cli-install-input.php b/core/tests/Fixtures/cli-install-input.php new file mode 100644 index 0000000000..c3c56aff02 --- /dev/null +++ b/core/tests/Fixtures/cli-install-input.php @@ -0,0 +1,42 @@ +ask('Database name?', 'fallback'); + break; + case 'choice': + echo 'ANSWER:' . $installer->choice('Install or update?', ['Install', 'Update']); + break; + case 'prefix-recovery': + $installer->dbh = new PDO('sqlite::memory:'); + $installer->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $installer->dbh->exec('CREATE TABLE evo_site_content (id INTEGER)'); + $installer->tablePrefix = 'evo_'; + $installer->checkIssetTablePrefix(); + echo 'ANSWER:' . $installer->tablePrefix; + break; + default: + throw new RuntimeException('Unknown test mode'); + } +} catch (RuntimeException $exception) { + error($exception->getMessage()); + exit(1); +} diff --git a/core/tests/standalone/CliInstallInputTest.php b/core/tests/standalone/CliInstallInputTest.php new file mode 100644 index 0000000000..fef7e5e08d --- /dev/null +++ b/core/tests/standalone/CliInstallInputTest.php @@ -0,0 +1,105 @@ + $arguments PHP script path followed by its arguments. + * @return array{int, string} Exit status and combined output. + */ +$run = static function (array $arguments, string $input = '', bool $nullInput = false) use ($temp, $root): array { + $outputFile = $temp . '/output'; + $descriptors = [ + 0 => $nullInput ? ['file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'r'] : ['pipe', 'r'], + 1 => ['file', $outputFile, 'w'], + 2 => ['file', $outputFile, 'a'], + ]; + $process = proc_open([PHP_BINARY, ...$arguments], $descriptors, $pipes, $root . '/install'); + if (!is_resource($process)) { + throw new RuntimeException('Could not start PHP input probe'); + } + if (!$nullInput) { + if ($input !== '') { + fwrite($pipes[0], $input); + } + fclose($pipes[0]); + } + $deadline = microtime(true) + 5; + do { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + proc_close($process); + return [$exitCode, (string) file_get_contents($outputFile)]; + } + usleep(10000); + } while (microtime(true) < $deadline); + + proc_terminate($process, 9); + proc_close($process); + throw new RuntimeException('CLI input probe exceeded five seconds: ' . implode(' ', $arguments)); +}; + +$check = static function (array $result, int $exitCode, string $message): void { + if ($result[0] !== $exitCode || !str_contains($result[1], $message)) { + throw new RuntimeException('Unexpected CLI result: ' . var_export($result, true)); + } + if (preg_match('/\b(Fatal error|Warning|Notice|Deprecated):/', $result[1])) { + throw new RuntimeException('PHP diagnostic in CLI output: ' . $result[1]); + } +}; + +try { + foreach ([false, true] as $nullInput) { + // Exercise the real entrypoint and its RuntimeException -> exit 1 handling. + $check($run([$root . '/install/cli-install.php'], '', $nullInput), 1, + 'End of input while waiting for: Please choose your variant of install:'); + $check($run([$root . '/install/cli-install.php', '--typeInstall=1', '--databaseType=sqlite'], '', $nullInput), 1, + 'End of input while waiting for: Please enter database:'); + // EOF must not silently accept either kind of default. + $check($run([$fixture, 'ask'], '', $nullInput), 1, 'End of input while waiting for: Database name?'); + $check($run([$fixture, 'choice'], '', $nullInput), 1, 'End of input while waiting for: Install or update?'); + } + + $check($run([$fixture, 'ask'], "\n"), 0, 'ANSWER:fallback'); + $check($run([$fixture, 'ask'], " custom \n"), 0, 'ANSWER:custom'); + $check($run([$fixture, 'choice'], "\n"), 0, 'ANSWER:Install'); + $check($run([$fixture, 'choice'], "invalid\n2\n"), 0, 'ANSWER:Update'); + $check($run([$fixture, 'choice'], "invalid\n"), 1, 'End of input while waiting for: Install or update?'); + + // Real SQLite prefix collision with all CLI arguments supplied. The failure + // must leave the existing data and installer intact, even with removeInstall=y. + $database = new PDO('sqlite:' . $temp . '/database/existing.sqlite'); + $database->exec('CREATE TABLE evo_site_content (id INTEGER)'); + $database->exec('INSERT INTO evo_site_content VALUES (42)'); + foreach ([false, true] as $nullInput) { + $result = $run([$fixture, 'prefix-cli', $temp], '', $nullInput); + $check($result, 1, 'Table prefix already exists'); + $check($result, 1, 'End of input while waiting for: Please enter table_prefix:'); + if ((int) $database->query('SELECT id FROM evo_site_content')->fetchColumn() !== 42 + || !is_file($root . '/install/cli-install.php') + || is_file($temp . '/config/database/connections/default.php')) { + throw new RuntimeException('Prefix conflict modified data, wrote configuration or removed the installer'); + } + } + $check($run([$fixture, 'prefix-recovery'], "other_\n"), 0, 'ANSWER:other_'); + $check($run([$fixture, 'prefix-recovery'], "\n"), 1, 'End of input while waiting for: Please enter table_prefix:'); + echo "PASS: CLI EOF exits 1, defaults require a line, piped answers work, prefix conflict preserves data.\n"; +} finally { + $database = null; + foreach (glob($temp . '/database/*') ?: [] as $file) { + unlink($file); + } + @unlink($temp . '/output'); + rmdir($temp . '/database'); + rmdir($temp); +} diff --git a/install/cli-install.php b/install/cli-install.php index 8ef0171fe7..3561c60fa2 100644 --- a/install/cli-install.php +++ b/install/cli-install.php @@ -20,6 +20,8 @@ * * The sqlite database is created as core/database/<--database>.sqlite * Use --skipComposer=y to keep the already installed dependencies untouched. + * Missing or invalid values can prompt for input. EOF at a prompt exits with code 1; + * an empty input line, rather than EOF, is required to accept a prompt's default. * An update restores the packages of core/custom/composer.json unless --skipComposer=y is given, * at the versions the last restore noted; --updateCustomPackages=y lets Composer choose others. * When they cannot be restored, the update ends incomplete: exit code 1, install/ kept. @@ -49,6 +51,10 @@ function runCliInstall(array $argv): int exit(runCliInstall($argv)); } +/** + * Run CLI installation or update steps, prompting only for missing or invalid input. + * Input prompts throw on EOF so unattended runs terminate instead of retrying forever. + */ class InstallEvo { /** @@ -159,6 +165,7 @@ public function read_line($message, $message2 = "") * @param array $options ['Install', 'Update', …] * @param int $default Default option index * @return string Selected element of the $options array + * @throws RuntimeException When stdin reaches EOF before a selection is supplied. */ public function choice(string $question, array $options, int $default = 0): string { @@ -191,6 +198,9 @@ public function choice(string $question, array $options, int $default = 0): stri $max = count($options); do { $answer = readline("Choose [1-{$max}]: "); + if ($answer === false) { + throw new RuntimeException('End of input while waiting for: ' . $question . ' Supply the required CLI arguments or provide stdin.'); + } if ($answer === '' && isset($options[$default])) { return $options[$default]; } @@ -206,6 +216,7 @@ public function choice(string $question, array $options, int $default = 0): stri * @param string $question Question text («Please enter database server») * @param string|null $default Default value if Enter is pressed * @return string + * @throws RuntimeException When stdin reaches EOF; defaults apply only to an empty line. */ public function ask(string $question, ?string $default = null): string { @@ -217,6 +228,9 @@ public function ask(string $question, ?string $default = null): string echo PHP_EOL . $yellow . $question . $suffix . $reset . PHP_EOL; $answer = readline('➤ '); + if ($answer === false) { + throw new RuntimeException('End of input while waiting for: ' . $question . ' Supply the required CLI arguments or provide stdin.'); + } return $answer !== '' ? trim($answer) : (string) $default; } From cbc23d75d77e340da721c80f72095ebcde50ab75 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 4 Nov 2026 18:08:33 +0100 Subject: [PATCH 07/50] fix(installer): make "remove the install folder" work Co-Authored-By: Claude Opus 5.5 --- .../Feature/InstallerRemovalFlowTest.php | 307 ++++++++++++++++++ .../Security/ApacheConfigHardeningTest.php | 8 + install/src/controllers/install.php | 5 +- ng.inx | 4 +- 4 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 core/tests/Feature/InstallerRemovalFlowTest.php diff --git a/core/tests/Feature/InstallerRemovalFlowTest.php b/core/tests/Feature/InstallerRemovalFlowTest.php new file mode 100644 index 0000000000..ec40a0f8e4 --- /dev/null +++ b/core/tests/Feature/InstallerRemovalFlowTest.php @@ -0,0 +1,307 @@ +stop(1); + installerFlowDelete($site['root']); + } + return $site = null; + } + if ($site !== null) { + return $site; + } + + $root = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-installer-flow-' . bin2hex(random_bytes(8)); + installerFlowCopy(dirname(__DIR__, 3), $root); + foreach (['logs', 'cache', 'sessions'] as $folder) { + mkdir("$root/core/storage/$folder", 0777, true); + } + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($socket, false); + fclose($socket); + + // The settings of the test run must not reach the site: DB_* would win over its own connection. + $environment = array_map(static fn () => false, array_filter( + getenv(), + static fn (string $name) => preg_match('/^(DB_|EVO_|APP_)/', $name) === 1, + ARRAY_FILTER_USE_KEY + )); + $server = new Process([PHP_BINARY, '-d', 'opcache.enable=0', '-S', $address, '-t', $root], $root, $environment, null, null); + $server->start(); + + $url = "http://$address"; + for ($attempt = 0; $attempt < 100; $attempt++) { + $connection = @fsockopen('127.0.0.1', (int) substr(strrchr($address, ':'), 1), $code, $message, 0.1); + if ($connection !== false) { + fclose($connection); + break; + } + usleep(50000); + } + + return $site = ['root' => $root, 'url' => $url, 'server' => $server]; +} + +/** Back to a tree that was never installed, with the installer of the repository. */ +function installerFlowReset(): array +{ + $site = installerFlowSite(); + $root = $site['root']; + foreach ([ + 'core/.install', 'core/config/database/connections/default.php', 'core/database/flow.sqlite', + 'install.session.php', 'install', 'core/storage/bootstrap', 'assets/cache/installProc.inc.php', + ] as $path) { + installerFlowDelete("$root/$path"); + } + mkdir("$root/core/storage/bootstrap", 0777, true); + installerFlowCopy(dirname(__DIR__, 3) . '/install', "$root/install"); + + return $site; +} + +function installerFlowBrowser(array $site): Client +{ + return new Client([ + 'base_uri' => $site['url'], + 'cookies' => new CookieJar(), + 'http_errors' => false, + 'timeout' => 300, + 'headers' => ['Accept-Language' => 'en', 'Referer' => $site['url'] . '/install/'], + ]); +} + +/** + * The fields form#install_form submits as it stands, and where it posts them. + * + * @return array{action: string, fields: array} + */ +function installerFlowForm(string $html): array +{ + $document = new DOMDocument(); + @$document->loadHTML($html); + $form = $document->getElementById('install_form'); + expect($form)->toBeInstanceOf(DOMElement::class, 'no installer form in: ' . substr(strip_tags($html), 0, 500)); + + $fields = []; + foreach ((new DOMXPath($document))->query('.//input|.//select|.//textarea', $form) as $field) { + $name = $field->getAttribute('name'); + $type = strtolower($field->getAttribute('type')); + if ($name === '' || $field->hasAttribute('disabled') + || (in_array($type, ['checkbox', 'radio'], true) && !$field->hasAttribute('checked'))) { + continue; + } + if ($field->nodeName === 'select') { + $value = ''; + foreach ($field->getElementsByTagName('option') as $index => $option) { + if ($index === 0 || $option->hasAttribute('selected')) { + $value = $option->getAttribute('value'); + } + } + } else { + $value = $field->nodeName === 'textarea' ? $field->textContent : $field->getAttribute('value'); + } + if (str_ends_with($name, '[]')) { + $fields[substr($name, 0, -2)][] = $value; + } else { + $fields[$name] = $value; + } + } + + return ['action' => $form->getAttribute('action'), 'fields' => $fields]; +} + +function installerFlowPost(Client $browser, string $path, array $fields): string +{ + $response = $browser->post($path, ['form_params' => $fields]); + expect($response->getStatusCode())->toBe(200, "$path answered " . $response->getStatusCode()); + + return (string) $response->getBody(); +} + +/** Submits the forms from $page on, each with $choices, up to the page of results. */ +function installerFlowFinish(Client $browser, string $page, array $choices): string +{ + for ($step = 0; $step < 8 && !str_contains($page, 'remove_installer.processor.php'); $step++) { + $form = installerFlowForm($page); + $page = installerFlowPost( + $browser, + '/install/' . $form['action'], + array_replace($form['fields'], $choices) + ); + } + expect($page)->toContain('remove_installer.processor.php')->not->toContain('class="notok"'); + + return $page; +} + +/** @return string the page of results */ +function installerFlowInstall(Client $browser): string +{ + $page = (string) $browser->get('/install/index.php')->getBody(); + $form = installerFlowForm($page); + $page = installerFlowPost($browser, '/install/' . $form['action'], ['language' => 'en'] + $form['fields']); + $form = installerFlowForm($page); + $page = installerFlowPost($browser, '/install/' . $form['action'], ['installmode' => '0'] + $form['fields']); + + $connection = [ + 'database_type' => 'sqlite', 'method' => 'sqlite', 'host' => '', 'uid' => '', 'pwd' => '', + 'database_name' => 'flow', 'tableprefix' => 'evo_', 'installMode' => '0', + ]; + $collations = installerFlowPost($browser, '/install/index.php?s=1&action=connection/collation', $connection); + preg_match('/") + ->and($source)->not->toContain("?>>"); +}); + it('escapes group names in the access lists', function (string $file, string $expected) { expect(legacyManagerSource($file))->toContain($expected); })->with([ diff --git a/manager/actions/mutate_web_user.dynamic.php b/manager/actions/mutate_web_user.dynamic.php index 74a575b5a5..2490073efb 100755 --- a/manager/actions/mutate_web_user.dynamic.php +++ b/manager/actions/mutate_web_user.dynamic.php @@ -383,7 +383,7 @@ function evoRenderTvImageCheck(a) { $selectedtext = $row['id'] == $userdata['role'] ? "selected='selected'" : ''; } ?> - + From 3664c5f925b8625be044fc1c1d26e37c994e9fe4 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 14:55:28 +0100 Subject: [PATCH 30/50] fix(manager): render user group checkboxes outside the actor reach as read-only The save guard (UserGroupGuard) only lets a manager without manage_groups change the user groups it belongs to itself; edits to any other group are dropped. The user form still drew every group as a live checkbox, so such a manager could tick one and save, and the change silently vanished. Mirror the guard in the form: without manage_groups, the groups outside the actor's own membership are rendered disabled and greyed, with a short note explaining they are read-only. The server-side guard is unchanged and remains the authority. Co-Authored-By: Claude Opus 4.8 --- core/lang/az/global.php | 1 + core/lang/be/global.php | 1 + core/lang/bg/global.php | 1 + core/lang/cs/global.php | 1 + core/lang/da/global.php | 1 + core/lang/de/global.php | 1 + core/lang/en/global.php | 1 + core/lang/es/global.php | 1 + core/lang/et/global.php | 1 + core/lang/fa/global.php | 1 + core/lang/fi/global.php | 1 + core/lang/fr/global.php | 1 + core/lang/he/global.php | 1 + core/lang/hy/global.php | 1 + core/lang/it/global.php | 1 + core/lang/ja/global.php | 1 + core/lang/ka/global.php | 1 + core/lang/kk/global.php | 1 + core/lang/ky/global.php | 1 + core/lang/lt/global.php | 1 + core/lang/lv/global.php | 1 + core/lang/nl/global.php | 1 + core/lang/nn/global.php | 1 + core/lang/pl/global.php | 1 + core/lang/pt/global.php | 1 + core/lang/ro/global.php | 1 + core/lang/sk/global.php | 1 + core/lang/sv/global.php | 1 + core/lang/tg/global.php | 1 + core/lang/tk/global.php | 1 + core/lang/uk/global.php | 1 + core/lang/uz/global.php | 1 + core/lang/zh/global.php | 1 + .../Security/ManagerAuthorizationGapsTest.php | 12 +++++++++++ manager/actions/mutate_web_user.dynamic.php | 21 ++++++++++++++++++- 35 files changed, 65 insertions(+), 1 deletion(-) diff --git a/core/lang/az/global.php b/core/lang/az/global.php index 26351ef9e8..b2366411ef 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -515,6 +515,7 @@ $_lang["access_permissions_user_toggle"] = 'Giriş icazələrini dəyiş'; $_lang["access_permissions_user_groups"] = 'İstifadəçi qrupları'; $_lang["access_permissions_user_message"] = 'Bu istifadəçinin aid olduğu istifadəçi qruplarını seçin:'; +$_lang["access_permissions_user_group_locked"] = 'Boz rəngdə göstərilən qruplar sizin üzvü olmadığınız qruplardır. Onları yalnız administrator və ya "Sənəd və istifadəçi qruplarını idarə et" icazəsinə malik menecer dəyişə bilər, ona görə burada yalnız oxumaq üçündür.'; $_lang["access_permissions_users_in_group"] = 'Qrupdakı istifadəçilər:'; $_lang["access_permissions_users_tab"] = 'Mövcud istifadəçi qruplarını görün. Yeni qruplar yaradın, adlarını dəyişin, qrupları silin və hansı istifadəçilərin hansı qruplara aid olduğunu görün. İstifadəçini qrupa əlavə etmək və ya çıxarmaq üçün onu birbaşa redaktə edin. Administratorlar (ID-si 1 olan rol təyin edilmiş istifadəçilər) bütün resurslara avtomatik giriş əldə edirlər və onlara xüsusi qruplar təyin etməyə ehtiyac yoxdur.'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index e0f041c3c9..4717b7df6f 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -503,6 +503,7 @@ $_lang["access_permissions_user_toggle"] = 'Пераключыць правы доступу карыстальніка'; $_lang["access_permissions_user_groups"] = 'Групы карыстальнікаў'; $_lang["access_permissions_user_message"] = 'Выберыце, да якіх груп карыстальнікаў павінен належаць гэты карыстальнік:'; +$_lang["access_permissions_user_group_locked"] = 'Групы, паказаныя шэрым, знаходзяцца па-за вашым уласным сяброўствам. Змяніць іх можа толькі адміністратар або менеджэр з дазволам "Кіраванне дакументамі і групамі карыстальнікаў", таму тут яны даступныя толькі для чытання.'; $_lang["access_permissions_users_in_group"] = 'Карыстальнікі ў групе:'; $_lang["access_permissions_users_tab"] = 'Прагляд створаных груп карыстальнікаў. Таксама вы можаце ствараць новыя групы, змяняць іх назвы, выдаляць групы і бачыць, якія карыстальнікі ўваходзяць у розныя групы. Каб дадаць новага карыстальніка ў групу або выдаліць карыстальніка з групы, адрэдагуйце карыстальніка непасрэдна. Адміністратары (карыстальнікі з роляй ID 1) заўсёды маюць доступ да ўсіх рэсурсаў, таму іх не трэба дадаваць у якія-небудзь групы.'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 3d3cb3875c..08384a82d8 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Тук можете да видите коя Група Документи е настроена. Също така можете да създадете Нова Група, преименувате Групи, изтриете Групи и да видите кои Документи са в различните Групи (сложете мишката върху id на Документа, за да видите неговото име). За да добавите или махнете Документ от групата, редактирайте директно.'; $_lang["access_permissions_user_groups"] = 'Потребителски групи'; $_lang["access_permissions_user_message"] = 'Тук можете да изберете към кои Потребителски Групи принадлежи този Потребител:'; +$_lang["access_permissions_user_group_locked"] = 'Групите, показани в сиво, са извън вашето собствено членство. Само администратор или мениджър с правото "Управление на групи документи и потребители" може да ги променя, затова тук са само за четене.'; $_lang["access_permissions_users_in_group"] = 'Потребители в група: '; $_lang["access_permissions_users_tab"] = 'Тук можете да видите коя Група Потребители е настроена. Можете също да създавате Нови Групи, да преименувате Групи, да изтривате Групи и да видите Потребителите в различните Групи. За да добавите или премахнете Нов Потребител в дадена Група, редактирайте Потребителя директно. Администраторите (потребители, които имам присвоена Роля с ID 1) винаги имат достъп до всички документи, така че не е необходимо те да бъдат добавяни в каквато и да е група.'; $_lang["account_email"] = 'Емейл на акаунта'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 251bb590b6..8ec5696b99 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Tady můžete vidět, které skupiny dokumentů jsou nastavené. Můžete vytvářet nové skupiny, přejmenovávat je, mazat je a procházet, které dokumenty se nacházejí, v kterých skupinách (po přesunu kurzoru myši na ID dokumentu se zobrazí jeho název). Přidat dokument do skupiny nebo ho odstranit se da jen prímou změnou v samotném dokumentu.'; $_lang["access_permissions_user_groups"] = 'Skupiny návštěvníků'; $_lang["access_permissions_user_message"] = 'Tady můžete zvolit, který uživatel patří, do které skupiny:'; +$_lang["access_permissions_user_group_locked"] = 'Skupiny zobrazené šedě jsou mimo vaše vlastní členství. Změnit je může pouze administrátor nebo správce s oprávněním "Správa skupin dokumentů a uživatelů", proto jsou zde pouze ke čtení.'; $_lang["access_permissions_users_in_group"] = 'Uživatelé ve skupině: '; $_lang["access_permissions_users_tab"] = 'Tady můžete vidět, které skupiny uživatelů jsou nastavené. Můžete vytvářet nové skupiny, přejmenovávat je a prohlížet, kteří uživatelé jsou ve skupinách registrováni. Jestliže chcete přidat nového uživatele do skupiny nebo ho odstranit, musíte upravit nastavení konkrétního uživatele ve "Správě uživatelů". Administrátoři (užívatelé s přiděleným ID 1) mají vždy přístup ke všem dokumentům a není potřeba je přidávat do kterékoli skupiny.'; $_lang["account_email"] = 'Email účtu'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index ab3f308077..3e92a70dae 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = 'Se hvilke ressource grupper der er oprettet. Du kan også oprette nye grupper, omdøbe grupper, slette grupper, samt se hvilke ressourcer der er i de respektive grupper. (Hold musen over ressourcens ID for at se dets navn). For at tilføje eller fjerne en ressource til en gruppe, skal du redigere selve ressourcen.'; $_lang["access_permissions_user_groups"] = 'Bruger grupper'; $_lang["access_permissions_user_message"] = 'Vælg bruger gruppen som denne bruger skal tilhøre:'; +$_lang["access_permissions_user_group_locked"] = 'Grupper vist i gråt er uden for dit eget medlemskab. Kun en administrator eller en manager med tilladelsen "Administrer dokument- og brugergrupper" kan ændre dem, så de er skrivebeskyttede her.'; $_lang["access_permissions_users_in_group"] = 'Brugere i gruppen:'; $_lang["access_permissions_users_tab"] = 'Se hvilke bruger grupper der er oprettet. Du kan også oprette nye bruger grupper, omdøbe grupper, slette grupper, samt se hvilke brugere der tilhører de forskellige grupper. For at tilføje en ny bruger til en brugergruppe, eller fjerne en bruger i en brugergruppe, skal du redigere den specifikke bruger. Administratoren (dvs. den der har ID nr. 1) har altid adgang til alle ressourcer, så derfor skal administratoren ikke tilføjes til nogen af grupperne.'; $_lang["account_email"] = 'E-mail konto'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index 196daa3880..6c4bdfe710 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Hier sehen Sie die angelegten Ressourcen-Gruppen. Sie können auch neue Gruppen anlegen, Gruppen umbenennen, Gruppen löschen und sehen welche Ressourcen in den unterschiedlichen Gruppen enthalten sind (den Namen der Ressource sehen Sie, in dem Sie mit der Maus über die ID fahren). Um eine Ressource zu einer Gruppe hinzuzufügen oder von einer Gruppe zu entfernen, ändern Sie die entsprechende Ressource direkt.'; $_lang["access_permissions_user_groups"] = 'Benutzergruppen'; $_lang["access_permissions_user_message"] = 'Hier können Sie auswählen welcher Benutzergruppe dieser Benutzer zugeordnet ist:'; +$_lang["access_permissions_user_group_locked"] = 'Grau dargestellte Gruppen liegen außerhalb Ihrer eigenen Mitgliedschaft. Nur ein Administrator oder ein Manager mit der Berechtigung "Dokument- und Benutzergruppen verwalten" kann sie ändern, daher sind sie hier schreibgeschützt.'; $_lang["access_permissions_users_in_group"] = 'Benutzer in Gruppe:'; $_lang["access_permissions_users_tab"] = 'Hier sehen Sie die angelegten Benutzergruppen. Sie können auch neue Gruppen anlegen, Gruppen umbenennen, Gruppen löschen und die Zugehörigkeit der Benutzer zu Gruppen sehen. Um ein Benutzer zu einer Gruppe hinzuzufügen oder zu entfernen, bearbeiten Sie direkt den entsprechenden Benutzer. Administratoren (Benutzer mit der Rollen ID 1) haben immer Zugriff auf alle Ressourcen und müssen keiner speziellen Gruppen angehören.'; $_lang["account_email"] = 'E-Mail-Adresse'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 96c6eee69b..e4b1fe86f9 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'See which Resource Groups have been created. Also create new groups, rename groups, delete groups and see which Resources are in the different groups (hover over the id of the Resource to see its name). To add a Resource to a group or remove a Resource from a group, edit the Resource directly.'; $_lang["access_permissions_user_groups"] = 'User groups'; $_lang["access_permissions_user_message"] = 'Select which User Groups this user belongs to:'; +$_lang["access_permissions_user_group_locked"] = 'Groups shown in grey are outside your own membership. Only an administrator, or a manager with the "Manage document and user groups" permission, can change them, so they are read-only here.'; $_lang["access_permissions_users_in_group"] = 'Users in group:'; $_lang["access_permissions_users_tab"] = 'View the User Groups that have been created. You can also create new groups, rename groups, delete groups and see which users are members of the different groups. To add a new user to a group or to remove a user from a group, edit the user directly. Administrators (users who have been assigned the role with ID 1) always have access to all Resources, so they don\'t need to be added to any groups.'; $_lang["account_email"] = 'Account email'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index aff1897d3b..9fb5464e4e 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Aquí puedes ver qué grupos de documentos han sido configurados. También puedes crear nuevos grupos, renombrarlos, borrarlos y ver qué documentos están en los diferentes grupos (pon el mouse sobre el ID del documento para ver su nombre). Para añadir un documento a un grupo, o removerlo de un grupo, edita el documento directamente.'; $_lang["access_permissions_user_groups"] = 'Grupos de usuarios'; $_lang["access_permissions_user_message"] = 'Aquí puedes seleccionar a qué grupos de usuarios pertenece este usuario:'; +$_lang["access_permissions_user_group_locked"] = 'Los grupos mostrados en gris están fuera de su propia pertenencia. Solo un administrador o un gestor con el permiso "Administrar grupos de documentos y usuarios" puede cambiarlos, por lo que aquí son de solo lectura.'; $_lang["access_permissions_users_in_group"] = 'Usuarios en Grupo:'; $_lang["access_permissions_users_tab"] = 'Aquí puedes ver que grupos de usuarios han sido configurados. También puedes crear nuevos grupos, renombrarlos, borrarlos y ver qué usuarios son miembros de los diferentes grupos. Para añadir un nuevo usuario a un grupo, o removerlo de uno, edita el usuario directamente. Los Administradores (usuarios a los que se les ha asignado un rol con ID 1) siempre tienen acceso a todos los documentos, por lo que no necesitan ser añadidos a ningún grupo.'; $_lang["account_email"] = 'Email de la cuenta'; diff --git a/core/lang/et/global.php b/core/lang/et/global.php index 519b563b4d..ac3b120855 100644 --- a/core/lang/et/global.php +++ b/core/lang/et/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Vaadake loodud ressursirühmi. Saate ka luua uusi rühmi, neid ümber nimetada ja kustutada ning vaadata eri rühmadesse kuuluvaid ressursse (nime nägemiseks liikuge kursoriga ressursi ID-le). Ressursi lisamiseks rühma või sealt eemaldamiseks muutke ressurssi otse.'; $_lang["access_permissions_user_groups"] = 'Kasutajarühmad'; $_lang["access_permissions_user_message"] = 'Valige kasutajarühmad, kuhu see kasutaja kuulub:'; +$_lang["access_permissions_user_group_locked"] = 'Hallina kuvatud rühmad on väljaspool teie enda liikmesust. Neid saab muuta ainult administraator või haldur, kellel on õigus "Halda dokumendi- ja kasutajarühmi", seega on need siin ainult lugemiseks.'; $_lang["access_permissions_users_in_group"] = 'Kasutajad rühmas:'; $_lang["access_permissions_users_tab"] = 'Vaadake loodud kasutajarühmi. Saate ka luua uusi rühmi, neid ümber nimetada ja kustutada ning vaadata eri rühmadesse kuuluvaid kasutajaid. Kasutaja lisamiseks rühma või sealt eemaldamiseks muutke kasutajat otse. Administraatoritel (kasutajatel, kellele on määratud roll ID-ga 1) on alati juurdepääs kõigile ressurssidele, seega ei pea neid ühegi rühmaga liitma.'; $_lang["account_email"] = 'Konto e-posti aadress'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 4cc02c75c6..335b6f5ddd 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = 'در اینجا شما میتوانید مشاهده کنید که چه گروههای پرونده ای تهیه شده است. همچنین شما قادر خواهید بود گروههای جدیدی را تعریف و ایجاد کنید, نام گروه ها را تغییر دهید, گروه ها را حذف کنید و ببنید که چه پرونده ای در چه چه گروهی قرار دارد (نشانگر موس را روی ردیف یا شناسه ی پرونده برانید تا اسم آنرا مشاهده کنید). برای افزودن پرونده به گروه یا حذف پرونده از یک گروه, پوشه ی پرونده را ویرایش کنید.'; $_lang["access_permissions_user_groups"] = 'گروههای کاربرات'; $_lang["access_permissions_user_message"] = 'در اینجا میتوانید مشخص کنید که این کاربر به چه گروههایی از کاربری متعلق است:'; +$_lang["access_permissions_user_group_locked"] = 'گروه‌هایی که به رنگ خاکستری نمایش داده می‌شوند خارج از عضویت شما هستند. فقط یک مدیر کل یا مدیری با دسترسی "مدیریت گروه‌های سند و کاربر" می‌تواند آنها را تغییر دهد، بنابراین در اینجا فقط خواندنی هستند.'; $_lang["access_permissions_users_in_group"] = 'کاربران در گروه: '; $_lang["access_permissions_users_tab"] = 'در اینجا شما میتوانید مشاهده کنید که چه گروههای کاربری تعریف شده اند. همچنین شما میتوانید گروههای جدیدی ایجاد نمایید, تغییر نام دهید, گروه ها را حذف کنید و ببینید که کدام کاربران عضو گروههای مختلف دیگر هستند. برای افزودن کاربر جدید به یک گروه یا حذف یک کاربر از یک گروه, خود کاربر را مستقیما ویرایش کنید. مدیران کل (کاربرانی که با نقش اول یا 1 تعریف شده اند) همیشه به کلیه ی پرونده ها دسترسی خواهند داشت, به همین دلیل نیازی نیست که آنها به گروه خاصی اضافه شوند.'; $_lang["account_email"] = 'حساب ایمیل'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index a8c64f9a8c..5e81b9894c 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Muokkaa sivuryhmiä. Lisää yksittäinen sivu ryhmään tai poista yksittäinen sivu ryhmästä muokkaamalla suoraan kyseistä sivua.'; $_lang["access_permissions_user_groups"] = 'Ylläpidon käyttäjäryhmät'; $_lang["access_permissions_user_message"] = 'Valitse käyttäjän käyttäjäryhmät:'; +$_lang["access_permissions_user_group_locked"] = 'Harmaana näytetyt ryhmät ovat oman jäsenyytesi ulkopuolella. Vain ylläpitäjä tai hallitsija, jolla on oikeus "Hallitse asiakirja- ja käyttäjäryhmiä", voi muuttaa niitä, joten ne ovat tässä vain luettavissa.'; $_lang["access_permissions_users_in_group"] = 'Käyttäjiä ryhmässä: '; $_lang["access_permissions_users_tab"] = 'Muokkaa käyttäjäryhmiä. Lisää yksittäinen käyttäjä ryhmään tai poista yksittäinen käyttäjä ryhmästä muokkaamalla suoraan kyseistä käyttäjää. Käyttäjillä, joiden rooli on pääkäyttäjä (Adminstrator), on aina oikeudet kaikkiin sivuihin.'; $_lang["account_email"] = 'Käyttäjän sähköpostiosoite'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index ed9e413690..dd931bdea8 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -44,6 +44,7 @@ $_lang["access_permissions_resources_tab"] = 'Les Groupes de Ressources actuels sont affichés ci-dessous. Vous pouvez en créer de nouveaux, les renommer, les supprimer et voir quelles Ressources appartiennent aux différents groupes (le survol par le pointeur de l\'ID de la Ressource en affichera le nom). Pour ajouter une Ressource à un groupe ou pour l\'en retirer, modifiez directement la Ressource.'; $_lang["access_permissions_user_groups"] = 'Groupes d\'Utilisateurs'; $_lang["access_permissions_user_message"] = 'Vous pouvez choisir ici les Groupes d\'Utilisateurs auxquels cet utilisateur appartient'; +$_lang["access_permissions_user_group_locked"] = 'Les groupes affichés en gris sont en dehors de votre propre appartenance. Seul un administrateur ou un gestionnaire disposant de l\'autorisation "Gérer les groupes de documents et d\'utilisateurs" peut les modifier ; ils sont donc en lecture seule ici.'; $_lang["access_permissions_users_in_group"] = 'Utilisateurs dans le groupe:'; $_lang["access_permissions_users_tab"] = 'Les Groupes d\'Utilisateurs actuels sont affichés ci-dessous. Vous pouvez en créer de nouveaux, les renommer, les supprimer et voir quels utilisateurs sont membres des différents groupes. Pour ajouter un nouvel utilisateur à un groupe ou pour l\'en retirer, modifiez directement l\'utilisateur. Les administrateurs ont toujours accès à toutes les Ressources. Il n\'est donc pas nécessaire de les ajouter à un groupe en particulier.'; $_lang["account_email"] = 'Email du compte utilisateur'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index c65b26abdb..f1f8ce2fb8 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = 'כאן תוכלו לראות איזה קבוצות מסמכים הוגדרו. ניתן ליצור קבוצות חדשות, לשנות שם לקבוצות, למחוק קבוצות ולראות אילו מסמכים קיימים בקבוצות שונות (ניתן לראות את שם המסמך במעבר עכבר מעל מזהה המסמך). כדי להוסיף מסמך לקבוצה או להסיר מסמך מהקבוצה, יש לערוך את המסמך ישירות.'; $_lang["access_permissions_user_groups"] = 'קבוצות משתמשים'; $_lang["access_permissions_user_message"] = 'כאן אתה יכול לבחור לאילו קבוצות משתמשים משתמש זה שייך:'; +$_lang["access_permissions_user_group_locked"] = 'הקבוצות המוצגות באפור נמצאות מחוץ לחברות שלך. רק מנהל מערכת, או מנהל בעל ההרשאה "ניהול קבוצות מסמכים ומשתמשים", יכול לשנות אותן, ולכן הן לקריאה בלבד כאן.'; $_lang["access_permissions_users_in_group"] = 'משתמשים בקבוצות:'; $_lang["access_permissions_users_tab"] = 'כאן תוכלו לראות איזה קבוצות משתמשים הוגדרו. אתה יכול גם ליצור קבוצות חדשות, לשנות שם קבוצה, למחוק קבוצות ולראות מהם המשתמשים חברי הקבוצות השונות. כדי להוסיף משתמש חדש לקבוצה או להסיר משתמש מהקבוצה, יש לערוך את המשתמש ישירות. למנהלים (משתמשים שיש להם כבר את התפקיד שהוקצה עם מזהה 1) תמיד יש גישה לכל המסמכים, ולכן לא צריך להוסיף להם את כל הקבוצות.'; $_lang["account_email"] = 'חשבון מייל'; diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php index 5a678f4cce..9eb687a926 100644 --- a/core/lang/hy/global.php +++ b/core/lang/hy/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Դիտեք ստեղծված ռեսուրսների խմբերը։ Կարող եք նաև ստեղծել նոր խմբեր, վերանվանել կամ ջնջել խմբերը և տեսնել տարբեր խմբերի ռեսուրսները (անունը տեսնելու համար մկնիկը պահեք ռեսուրսի նույնացուցիչի վրա)։ Ռեսուրսը խմբում ավելացնելու կամ խմբից հեռացնելու համար խմբագրեք հենց ռեսուրսը։'; $_lang["access_permissions_user_groups"] = 'Օգտատերերի խմբեր'; $_lang["access_permissions_user_message"] = 'Ընտրեք օգտատերերի այն խմբերը, որոնց պատկանում է այս օգտատերը՝'; +$_lang["access_permissions_user_group_locked"] = 'Մոխրագույնով ցուցադրված խմբերը ձեր անդամակցությունից դուրս են։ Դրանք կարող է փոփոխել միայն ադմինիստրատորը կամ "Կառավարել փաստաթղթերի և օգտատերերի խմբերը" թույլտվությունն ունեցող կառավարիչը, ուստի այստեղ դրանք միայն ընթերցման համար են։'; $_lang["access_permissions_users_in_group"] = 'Խմբի օգտատերերը՝'; $_lang["access_permissions_users_tab"] = 'Դիտեք ստեղծված օգտատերերի խմբերը։ Կարող եք նաև ստեղծել նոր խմբեր, վերանվանել կամ ջնջել խմբերը և տեսնել, թե որ օգտատերերն են տարբեր խմբերի անդամներ։ Օգտատիրոջը խմբում ավելացնելու կամ խմբից հեռացնելու համար խմբագրեք հենց օգտատիրոջը։ Ադմինիստրատորները (1 նույնացուցիչով դեր ունեցող օգտատերերը) միշտ ունեն բոլոր ռեսուրսների հասանելիություն, ուստի նրանց որևէ խմբում ավելացնել պետք չէ։'; $_lang["account_email"] = 'Հաշվի էլ․ փոստ'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index 62d31ff99c..f6073ce4c4 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Qui potete vedere i Gruppi di Risorse che sono stati impostati. Potete anche crearne di nuovi, rinominarli, eliminarli e vedere quali Risorse sono presenti nei differenti gruppi (posizionate il mouse sull\'ID della Risorsa per vederne il nome). Per aggiungere una Risorsa ad un gruppo o per rimuoverla da un gruppo, modificate direttamente la Risorsa.'; $_lang["access_permissions_user_groups"] = 'Gruppi di Utenti'; $_lang["access_permissions_user_message"] = 'Qui potete selezionare a quali Gruppi di Utenti appartiene l\'utente:'; +$_lang["access_permissions_user_group_locked"] = 'I gruppi mostrati in grigio sono al di fuori della tua appartenenza. Solo un amministratore o un gestore con l\'autorizzazione "Gestisci gruppi di documenti e utenti" può modificarli, quindi qui sono di sola lettura.'; $_lang["access_permissions_users_in_group"] = 'Utenti del gruppo: '; $_lang["access_permissions_users_tab"] = 'Qui potete vedere i Gruppi di Utenti che sono stati impostati. Potete anche crearne di nuovi, rinominarli, eliminarli e vedere quali utenti fanno parte dei differenti gruppi. Per aggiungere un nuovo utente a un gruppo o per rimuoverlo da un gruppo, modificate direttamente l\'utente. Gli amministratori (utenti a cui è assegnato il Ruolo con ID 1) hanno sempre accesso a tutte le Risorse, quindi non hanno necessità di essere aggiunti ad alcun gruppo.'; $_lang["account_email"] = 'Indirizzo email'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 9f4787ee04..64c30f6381 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -44,6 +44,7 @@ $_lang["access_permissions_resources_tab"] = 'IDの上にマウスポインタを重ねるとリソース名を確認できます。リソースをグループに加えるには、リソース編集画面を個別に開いて「所属グループ」タブで設定してください。管理画面とウェブページを区別して扱わないため、両方のグループを作る場合はグループ名を工夫するとよいでしょう。'; $_lang["access_permissions_user_groups"] = 'ユーザーグループ'; $_lang["access_permissions_user_message"] = 'このユーザーを所属させたいユーザーグループを選択してください。ユーザは所属しているグループに割り当てられたリソースのみを編集できます。'; +$_lang["access_permissions_user_group_locked"] = 'グレーで表示されているグループは、あなた自身が所属していないグループです。管理者、または「ドキュメントとユーザーグループの管理」権限を持つマネージャーのみが変更でき、ここでは読み取り専用です。'; $_lang["access_permissions_users_in_group"] = 'グループ内ユーザー: '; $_lang["access_permissions_users_tab"] = '管理画面を扱うユーザのグループと、ウェブページにアクセスする権限を持つユーザのグループは区別されます。ユーザーをグループに参加させるには、個々のユーザー設定で編集する必要があります。※Administrator(ID=1のロールを持つユーザ)は常に全てのリソースにアクセスすることができるため、Administratorをグループに追加する必要はありません。'; $_lang["account_email"] = 'メールアドレス'; diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php index f59adc9cce..791184cef2 100644 --- a/core/lang/ka/global.php +++ b/core/lang/ka/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'იხილეთ შექმნილი რესურსების ჯგუფები. ასევე შეგიძლიათ ჯგუფების შექმნა, სახელის შეცვლა, წაშლა და მათში შემავალი რესურსების ნახვა (სახელის სანახავად კურსორი რესურსის ID-ზე მიიტანეთ). რესურსის ჯგუფში დასამატებლად ან ჯგუფიდან ამოსაშლელად უშუალოდ რესურსი შეცვალეთ.'; $_lang["access_permissions_user_groups"] = 'მომხმარებლების ჯგუფები'; $_lang["access_permissions_user_message"] = 'აირჩიეთ მომხმარებლების ჯგუფები, რომლებსაც ეს მომხმარებელი ეკუთვნის:'; +$_lang["access_permissions_user_group_locked"] = 'ნაცრისფრად ნაჩვენები ჯგუფები თქვენი წევრობის გარეთაა. მათი შეცვლა შეუძლია მხოლოდ ადმინისტრატორს ან მენეჯერს, რომელსაც აქვს უფლება "დოკუმენტებისა და მომხმარებლების ჯგუფების მართვა", ამიტომ აქ ისინი მხოლოდ წასაკითხად არის.'; $_lang["access_permissions_users_in_group"] = 'მომხმარებლები ჯგუფში:'; $_lang["access_permissions_users_tab"] = 'იხილეთ შექმნილი მომხმარებლების ჯგუფები. ასევე შეგიძლიათ ჯგუფების შექმნა, სახელის შეცვლა, წაშლა და მათი წევრების ნახვა. მომხმარებლის ჯგუფში დასამატებლად ან ჯგუფიდან ამოსაშლელად უშუალოდ მომხმარებელი შეცვალეთ. ადმინისტრატორებს (მომხმარებლებს, რომელთა როლის ID არის 1) ყველა რესურსზე ყოველთვის აქვთ წვდომა, ამიტომ მათი ჯგუფებში დამატება საჭირო არ არის.'; $_lang["account_email"] = 'ანგარიშის ელფოსტა'; diff --git a/core/lang/kk/global.php b/core/lang/kk/global.php index fe39072d02..eecc0f2bf2 100644 --- a/core/lang/kk/global.php +++ b/core/lang/kk/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Жасалған ресурс топтарын қараңыз. Жаңа топтар жасауға, оларды қайта атауға, жоюға және әр топтағы ресурстарды көруге болады (ресурс атауын көру үшін оның ID үстіне меңзерді апарыңыз). Ресурсты топқа қосу немесе топтан шығару үшін ресурстың өзін өңдеңіз.'; $_lang["access_permissions_user_groups"] = 'Пайдаланушы топтары'; $_lang["access_permissions_user_message"] = 'Осы пайдаланушы кіретін топтарды таңдаңыз:'; +$_lang["access_permissions_user_group_locked"] = 'Сұр түспен көрсетілген топтар сіздің мүшелігіңізден тыс. Оларды тек әкімші немесе "Құжат және пайдаланушы топтарын басқару" рұқсаты бар менеджер өзгерте алады, сондықтан олар мұнда тек оқуға арналған.'; $_lang["access_permissions_users_in_group"] = 'Топтағы пайдаланушылар:'; $_lang["access_permissions_users_tab"] = 'Жасалған пайдаланушы топтарын қараңыз. Топтарды жасауға, қайта атауға, жоюға және олардың мүшелерін көруге болады. Пайдаланушыны топқа қосу немесе одан шығару үшін пайдаланушының өзін өңдеңіз. Әкімшілер (рөлінің ID мәні 1) барлық ресурстарға қатынай алады, оларды топтарға қосу қажет емес.'; $_lang["account_email"] = 'Тіркелгінің электрондық поштасы'; diff --git a/core/lang/ky/global.php b/core/lang/ky/global.php index d93a1aeaaa..8daa55d940 100644 --- a/core/lang/ky/global.php +++ b/core/lang/ky/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Түзүлгөн ресурс топторун караңыз. Жаңы топ түзүп, атын өзгөртүп, өчүрүп жана топтогу ресурстарды көрө аласыз (аталышын көрүү үчүн ресурстун ID маанисине көрсөткүчтү алып барыңыз). Ресурсту топко кошуу же андан чыгаруу үчүн ресурстун өзүн түзөтүңүз.'; $_lang["access_permissions_user_groups"] = 'Колдонуучу топтору'; $_lang["access_permissions_user_message"] = 'Бул колдонуучу кире турган топторду тандаңыз:'; +$_lang["access_permissions_user_group_locked"] = 'Боз түс менен көрсөтүлгөн топтор сиздин мүчөлүгүңүздөн тышкары. Аларды администратор же "Документ жана колдонуучу топторун башкаруу" уруксаты бар менеджер гана өзгөртө алат, ошондуктан бул жерде алар окуу үчүн гана.'; $_lang["access_permissions_users_in_group"] = 'Топтогу колдонуучулар:'; $_lang["access_permissions_users_tab"] = 'Түзүлгөн колдонуучу топторун карап, жаңысын түзүп, атын өзгөртүп, өчүрүп жана мүчөлөрүн көрө аласыз. Колдонуучуну топко кошуу же андан чыгаруу үчүн колдонуучунун өзүн түзөтүңүз. Администраторлор (ID 1 ролундагы колдонуучулар) бардык ресурстарга дайыма кире алышат, аларды топко кошуу зарыл эмес.'; $_lang["account_email"] = 'Каттоо эсебинин электрондук почтасы'; diff --git a/core/lang/lt/global.php b/core/lang/lt/global.php index 3e899fe468..6f6d97bada 100644 --- a/core/lang/lt/global.php +++ b/core/lang/lt/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Peržiūrėkite sukurtas išteklių grupes. Taip pat galite kurti naujas grupes, jas pervadinti, ištrinti ir peržiūrėti skirtingų grupių išteklius (užveskite pelės žymeklį ant ištekliaus ID, kad pamatytumėte jo pavadinimą). Norėdami pridėti išteklių prie grupės arba jį pašalinti, redaguokite patį išteklių.'; $_lang["access_permissions_user_groups"] = 'Naudotojų grupės'; $_lang["access_permissions_user_message"] = 'Pasirinkite naudotojų grupes, kurioms priklauso šis naudotojas:'; +$_lang["access_permissions_user_group_locked"] = 'Pilkai rodomos grupės yra už jūsų narystės ribų. Jas keisti gali tik administratorius arba vadybininkas, turintis teisę "Tvarkyti dokumentų ir naudotojų grupes", todėl čia jos skirtos tik skaityti.'; $_lang["access_permissions_users_in_group"] = 'Grupės naudotojai:'; $_lang["access_permissions_users_tab"] = 'Peržiūrėkite sukurtas naudotojų grupes. Taip pat galite kurti naujas grupes, jas pervadinti, ištrinti ir peržiūrėti skirtingų grupių narius. Norėdami pridėti naudotoją prie grupės arba jį pašalinti, redaguokite patį naudotoją. Administratoriai (naudotojai, kuriems priskirtas vaidmuo, kurio ID yra 1) visada turi prieigą prie visų išteklių, todėl jų nereikia pridėti prie jokių grupių.'; $_lang["account_email"] = 'Paskyros el. paštas'; diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php index edeec426bd..4d7e37b5e2 100644 --- a/core/lang/lv/global.php +++ b/core/lang/lv/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Apskatiet izveidotās resursu grupas. Varat arī veidot jaunas grupas, pārdēvēt un dzēst tās, kā arī apskatīt dažādu grupu resursus (novietojiet kursoru virs resursa ID, lai redzētu tā nosaukumu). Lai pievienotu resursu grupai vai izņemtu to no grupas, rediģējiet pašu resursu.'; $_lang["access_permissions_user_groups"] = 'Lietotāju grupas'; $_lang["access_permissions_user_message"] = 'Izvēlieties lietotāju grupas, kurām šis lietotājs pieder:'; +$_lang["access_permissions_user_group_locked"] = 'Pelēkā krāsā parādītās grupas ir ārpus jūsu dalības. Tās var mainīt tikai administrators vai vadītājs ar atļauju "Pārvaldīt dokumentu un lietotāju grupas", tāpēc šeit tās ir tikai lasāmas.'; $_lang["access_permissions_users_in_group"] = 'Lietotāji grupā:'; $_lang["access_permissions_users_tab"] = 'Apskatiet izveidotās lietotāju grupas. Varat arī veidot jaunas grupas, pārdēvēt un dzēst tās, kā arī apskatīt dažādu grupu dalībniekus. Lai pievienotu lietotāju grupai vai izņemtu to no grupas, rediģējiet pašu lietotāju. Administratoriem (lietotājiem, kuriem piešķirta loma ar ID 1) vienmēr ir piekļuve visiem resursiem, tāpēc tie nav jāpievieno nevienai grupai.'; $_lang["account_email"] = 'Konta e-pasta adrese'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 0c09b6214f..dfcb01564b 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Bekijk hier de Paginagroepen. U kunt ook nieuwe groepen maken, bestaande bewerken of verwijderen of nagaan welke Pagina deel uitmaakt van een groep. Door met de muis over de ID van de Pagina te bewegen, ziet u de naam ervan. Door de Pagina te bewerken kunt u de groepsrechten wijzigen.'; $_lang["access_permissions_user_groups"] = 'Gebruikersgroepen'; $_lang["access_permissions_user_message"] = 'Geef aan tot welke groepen deze Gebruiker behoort:'; +$_lang["access_permissions_user_group_locked"] = 'Grijs weergegeven groepen vallen buiten uw eigen lidmaatschap. Alleen een beheerder of een manager met de rechten "Document- en gebruikersgroepen beheren" kan ze wijzigen, dus ze zijn hier alleen-lezen.'; $_lang["access_permissions_users_in_group"] = 'Gebruikers in deze groep:'; $_lang["access_permissions_users_tab"] = 'Bekijk de Gebruikersgroepen. U kunt ook nieuwe groepen maken, bestaande bewerken of verwijderen, of nagaan wie deel uitmaakt van een groep. Om een Gebruiker uit een groep te halen of aan een groep toe te wijzen, wijzigt u de gegevens van de Gebruiker. Beheerders (dit zijn Gebruikers met profiel ID 1) hebben altijd toegang tot alle Pagina\'s dus hoeft u hen niet toe te voegen aan een groep.'; $_lang["account_email"] = 'E-mail account'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 29c85219be..8cf08993ae 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Her kan du se hvilke dokumentgrupper som har blitt konfigurert. Du kan til og med lage nye og bytte navn på grupper, samt se hvilke dokumenter som tilhører en spesifikk gruppe (hold musepekeren over dokumentets ID for å se navnet på dokumentet). For å legge til eller fjerne et dokument fra en gruppe, rediger dokumentet direkte.'; $_lang["access_permissions_user_groups"] = 'Brukergrupper'; $_lang["access_permissions_user_message"] = 'Her kan du velge hvilke brukergrupper denne brukeren skal tilhøre:'; +$_lang["access_permissions_user_group_locked"] = 'Grupper vist i grått er utanfor ditt eige medlemskap. Berre ein administrator eller ein leiar med løyvet "Administrer dokument- og brukargrupper" kan endra dei, så dei er skriveverna her.'; $_lang["access_permissions_users_in_group"] = 'Brukere i gruppen: '; $_lang["access_permissions_users_tab"] = 'Her kan du se hvilke brukergrupper som har blitt konfigurert. Du kan også opprette nye, bytte navn på og fjerne grupper, samt se hvilke brukere som er medlemmer i en spesifikk gruppe. For å legge til eller fjerne en bruker fra en gruppe, rediger brukeren direkte. Administratorer (som har rolle-id 1) har alltid tillgang til alle dokumenter, så de trenger ikke å legges til noen grupper.'; $_lang["account_email"] = 'Konto e-post'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index c9a169b2bf..4d8ac92f31 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = 'Tutaj możesz zobaczyć jakie grupy dokumentów zostały stworzone. Możesz także stworzyć nowe grupy, zmieniać nazwy, usuwać i sprawdzać które dokumenty należą do danej grupy (najedź kursorem na ID dokumentu, aby zobaczyć jego nazwę). Aby dodać dokument do grupy albo usunąć go z grupy, edytuj dokument bezpośrednio.'; $_lang["access_permissions_user_groups"] = 'Grupy użytkowników'; $_lang["access_permissions_user_message"] = 'Wybierz do których grup należy ten użytkownik:'; +$_lang["access_permissions_user_group_locked"] = 'Grupy wyświetlone na szaro znajdują się poza Twoim członkostwem. Mogą je zmieniać tylko administrator lub menedżer z uprawnieniem "Zarządzanie grupami dokumentów i użytkowników", dlatego tutaj są tylko do odczytu.'; $_lang["access_permissions_users_in_group"] = 'Użytkownicy w grupie: '; $_lang["access_permissions_users_tab"] = 'Tutaj możesz zobaczyć jakie grupy użytkowników zostały stworzone. Możesz także stworzyć nowe grupy, zmieniać nazwy, usuwać i sprawdzać którzy użytkownicy należą do danej grupy. Aby dodać nowego użytkownika do grupy albo usunać go z grupy, edytuj tego użytkownika bezpośrednio. Administratorzy (ID Roli 1) zawsze mają dostęp do wszystkich dokumentów, więc nie muszą być przypisani do żadnej grupy.'; $_lang["account_email"] = 'Konto e-mail'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index d1bbf03eaa..47dfeee49a 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Aqui pode ver que grupos de documentos estão definidos. Pode criar novos grupos, alterar o nome dos grupos, apagar grupos e ver que documentos pertencem aos diferentes grupos. Para adicionar ou remover um documento de um grupo, edite-o directamente.'; $_lang["access_permissions_user_groups"] = 'Grupos de utilizadores'; $_lang["access_permissions_user_message"] = 'Aqui pode seleccionar a que grupos este utilizador pertence.'; +$_lang["access_permissions_user_group_locked"] = 'Os grupos apresentados a cinzento estão fora da sua associação. Apenas um administrador ou um gestor com a permissão "Gerir grupos de documentos e utilizadores" os pode alterar, pelo que aqui são apenas de leitura.'; $_lang["access_permissions_users_in_group"] = 'Utilizadores no grupo: '; $_lang["access_permissions_users_tab"] = 'Aqui pode ver que grupos de utilizadores estão definidos. Pode criar novos grupos, alterar o nome dos grupos, apagar grupos e ver quem são os membros dos diferentes grupos. Para adicionar ou remover um utilizador de um grupo, edite o utilizador directamente. Administradores (utilizadores a quem foi atribuído o perfil com ID 1) têm sempre acesso a todos os documentos e não precisam de ser adicionados a nenhum grupo.'; $_lang["account_email"] = 'Conta de E-mail'; diff --git a/core/lang/ro/global.php b/core/lang/ro/global.php index 3035faaf3e..9929cfbd56 100644 --- a/core/lang/ro/global.php +++ b/core/lang/ro/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Vizualizați grupurile de resurse create. Puteți crea grupuri noi, redenumi sau șterge grupuri și vedea resursele din fiecare grup (treceți cursorul peste ID-ul resursei pentru a-i vedea numele). Pentru a adăuga o resursă într-un grup sau a o elimina dintr-un grup, editați direct resursa.'; $_lang["access_permissions_user_groups"] = 'Grupuri de utilizatori'; $_lang["access_permissions_user_message"] = 'Selectați grupurile de utilizatori din care face parte acest utilizator:'; +$_lang["access_permissions_user_group_locked"] = 'Grupurile afișate cu gri se află în afara apartenenței dumneavoastră. Doar un administrator sau un manager cu permisiunea "Gestionarea grupurilor de documente și utilizatori" le poate modifica, așa că aici sunt doar pentru citire.'; $_lang["access_permissions_users_in_group"] = 'Utilizatori în grup:'; $_lang["access_permissions_users_tab"] = 'Vizualizați grupurile de utilizatori create. Puteți crea grupuri noi, redenumi sau șterge grupuri și vedea utilizatorii din fiecare grup. Pentru a adăuga un utilizator într-un grup sau a-l elimina dintr-un grup, editați direct utilizatorul. Administratorii (utilizatorii cărora li s-a atribuit rolul cu ID-ul 1) au întotdeauna acces la toate resursele, deci nu trebuie adăugați în niciun grup.'; $_lang["account_email"] = 'Adresa de e-mail a contului'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 002a8bbe48..583272634b 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = 'Tu môžete vidieť všetky skupiny zdrojov. Tiež tu môžete vytvoriť, premenovať, odstrániť skupinu a zobraziť zdroje v skupine. Ak chcete pridať alebo odstrániť zdroj zo skupiny, upravte priamo daný zdroj.'; $_lang["access_permissions_user_groups"] = 'Skupiny používateľov'; $_lang["access_permissions_user_message"] = 'Tu môžete vybrať, do ktorej skupiny používateľov tento používateľ patrí:'; +$_lang["access_permissions_user_group_locked"] = 'Skupiny zobrazené sivou farbou sú mimo vášho členstva. Zmeniť ich môže iba administrátor alebo správca s oprávnením "Správa skupín dokumentov a používateľov", preto sú tu len na čítanie.'; $_lang["access_permissions_users_in_group"] = 'Používatelia v skupine:'; $_lang["access_permissions_users_tab"] = 'Tu môžete vidieť všetky skupiny používateľov. Tiež tu môžete vytvoriť, premenovať, odstrániť skupinu a zobraziť členov skupiny. Ak chcete pridať alebo odstrániť používateľa zo skupiny, upravte priamo daného používateľa. Manažéri majú vždy prístup ku všetkým zdrojom.'; $_lang["account_email"] = 'E-mail účtu'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index de6aada43b..91f7624f91 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = 'Se vilka resursgrupper som har skapats. Det går också att skapa nya eller byta namn på grupper samt se vilka resurser som tillhör de olika grupperna (håll musen över resursens ID för att se dess namn). För att lägga till eller ta bort en resurs från en grupp - redigera resursen direkt.'; $_lang["access_permissions_user_groups"] = 'Användargrupper'; $_lang["access_permissions_user_message"] = 'Ange vilka användargrupper denna användare ska tillhöra:'; +$_lang["access_permissions_user_group_locked"] = 'Grupper som visas i grått ligger utanför ditt eget medlemskap. Endast en administratör eller en förvaltare med behörigheten "Hantera dokument- och användargrupper" kan ändra dem, så de är skrivskyddade här.'; $_lang["access_permissions_users_in_group"] = 'Användare i gruppen:'; $_lang["access_permissions_users_tab"] = 'Se de användargrupper som har skapats. Du kan också skapa nya, byta namn på och ta bort grupper samt se vilka användare som är medlemmar i de olika grupperna. För att lägga till eller ta bort en användare från en grupp - redigera användaren direkt. Administratörer (som har roll-ID 1) har alltid tillgång till alla resurser, så de behöver inte läggas till i några grupper.'; $_lang["account_email"] = 'Kontots e-post'; diff --git a/core/lang/tg/global.php b/core/lang/tg/global.php index 87bfbc7db0..bfb7a618bb 100644 --- a/core/lang/tg/global.php +++ b/core/lang/tg/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Гурӯҳҳои сохташударо бинед, гурӯҳ созед, номашро иваз ё ҳазф кунед ва захираҳои гурӯҳҳоро бинед (барои дидани ном нишонаро болои ID гузоред). Барои илова ё хориҷ кардани захира аз гурӯҳ худи захираро таҳрир кунед.'; $_lang["access_permissions_user_groups"] = 'Гурӯҳҳои корбарон'; $_lang["access_permissions_user_message"] = 'Гурӯҳҳоеро интихоб кунед, ки корбар ба онҳо тааллуқ дорад:'; +$_lang["access_permissions_user_group_locked"] = 'Гурӯҳҳое, ки бо ранги хокистарӣ нишон дода шудаанд, берун аз узвияти шумо мебошанд. Онҳоро танҳо администратор ё менеҷере, ки иҷозати "Идоракунии гурӯҳҳои ҳуҷҷатҳо ва корбарон"-ро дорад, тағйир дода метавонад, бинобар ин дар ин ҷо онҳо танҳо барои хондан мебошанд.'; $_lang["access_permissions_users_in_group"] = 'Корбарони гурӯҳ:'; $_lang["access_permissions_users_tab"] = 'Гурӯҳҳои корбаронро бинед, созед, номашонро иваз ё ҳазф кунед ва аъзои гурӯҳҳоро бинед. Барои илова ё хориҷ кардани корбар аз гурӯҳ худи корбарро таҳрир кунед. Мудирон (нақши ID 1) ҳамеша ба ҳамаи захираҳо дастрасӣ доранд ва ба узвияти гурӯҳ ниёз надоранд.'; $_lang["account_email"] = 'Почтаи электронии ҳисоб'; diff --git a/core/lang/tk/global.php b/core/lang/tk/global.php index 37c8a236a4..2b38d202e1 100644 --- a/core/lang/tk/global.php +++ b/core/lang/tk/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Döredilen resurs toparlaryny görüň, täze topar dörediň, adyny üýtgediň ýa-da pozuň. Resursyň adyny görmek üçin onuň ID-siniň üstüne syçanjygy eltiň. Resursy topara goşmak ýa-da aýyrmak üçin resursyň özüni redaktirläň.'; $_lang["access_permissions_user_groups"] = 'Ulanyjy toparlary'; $_lang["access_permissions_user_message"] = 'Bu ulanyjynyň degişli toparlaryny saýlaň:'; +$_lang["access_permissions_user_group_locked"] = 'Çal reňkde görkezilen toparlar siziň agzalygyňyzdan daşarda. Olary diňe administrator ýa-da "Resminama we ulanyjy toparlaryny dolandyrmak" rugsady bolan menejer üýtgedip biler, şonuň üçin bu ýerde olar diňe okamak üçindir.'; $_lang["access_permissions_users_in_group"] = 'Topardaky ulanyjylar:'; $_lang["access_permissions_users_tab"] = 'Döredilen ulanyjy toparlaryny we olaryň agzalaryny görüň. Toparlary döredip, atlaryny üýtgedip we pozup bilersiňiz. Ulanyjyny goşmak ýa-da aýyrmak üçin ulanyjynyň özüni redaktirläň. ID-si 1 bolan roldaky dolandyryjylar ähli resurslara girip bilýärler, olary toparlara goşmak gerek däl.'; $_lang["account_email"] = 'Hasabyň e-poçtasy'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 9418309233..bc1319a440 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = 'Тут ви можете побачити всі групи ресурсів. Також, тут ви можете створити, перейменувати, видалити групу, переглянути ресурси в групі. Для додавання або видалення ресурсу з групи перейдіть в редагування ресурсу.'; $_lang["access_permissions_user_groups"] = 'Групи користувачів'; $_lang["access_permissions_user_message"] = 'Тут ви можете вибрати, до якої групи користувачів належить цей користувач:'; +$_lang["access_permissions_user_group_locked"] = 'Групи, показані сірим, перебувають поза вашим членством. Змінювати їх може лише адміністратор або менеджер з дозволом "Керування групами документів і користувачів", тому тут вони доступні лише для читання.'; $_lang["access_permissions_users_in_group"] = 'Користувачі в групі:'; $_lang["access_permissions_users_tab"] = 'Тут ви можете побачити всі групи користувачів. Також, тут ви можете створити, перейменувати, видалити групу, переглянути членів групи. Щоб додати або видалити користувача з групи редагуйте користувача безпосередньо. Менеджери завжди мають доступ до всіх ресурсів.'; $_lang["account_email"] = 'E-mail облікового запису'; diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php index 7e00d7c075..1d858c0b7e 100644 --- a/core/lang/uz/global.php +++ b/core/lang/uz/global.php @@ -41,6 +41,7 @@ $_lang["access_permissions_resources_tab"] = 'Yaratilgan resurslar guruhlarini ko\'ring. Shuningdek, yangi guruhlar yaratishingiz, ularni qayta nomlashingiz, o\'chirishingiz va turli guruhlardagi resurslarni ko\'rishingiz mumkin (resurs nomini ko\'rish uchun uning ID raqamiga kursorni olib boring). Resursni guruhga qo\'shish yoki undan chiqarish uchun resursning o\'zini tahrirlang.'; $_lang["access_permissions_user_groups"] = 'Foydalanuvchilar guruhlari'; $_lang["access_permissions_user_message"] = 'Bu foydalanuvchi a\'zo bo\'lgan foydalanuvchilar guruhlarini tanlang:'; +$_lang["access_permissions_user_group_locked"] = 'Kulrang ko\'rsatilgan guruhlar sizning a\'zoligingizdan tashqarida. Ularni faqat administrator yoki "Hujjatlar va foydalanuvchilar guruhlarini boshqarish" ruxsatiga ega menejer o\'zgartira oladi, shuning uchun ular bu yerda faqat o\'qish uchun.'; $_lang["access_permissions_users_in_group"] = 'Guruhdagi foydalanuvchilar:'; $_lang["access_permissions_users_tab"] = 'Yaratilgan foydalanuvchilar guruhlarini ko\'ring. Shuningdek, yangi guruhlar yaratishingiz, ularni qayta nomlashingiz, o\'chirishingiz va turli guruhlar a\'zolarini ko\'rishingiz mumkin. Foydalanuvchini guruhga qo\'shish yoki undan chiqarish uchun foydalanuvchining o\'zini tahrirlang. Administratorlar (ID raqami 1 bo\'lgan rol berilgan foydalanuvchilar) doimo barcha resurslarga kira oladi, shuning uchun ularni hech qanday guruhga qo\'shish shart emas.'; $_lang["account_email"] = 'Hisobning elektron pochta manzili'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 91448fb180..5a3d374dd9 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -42,6 +42,7 @@ $_lang["access_permissions_resources_tab"] = '你可以在这里查看建立了哪些文档组。你也可以建立/重命名/删除组。查看哪些文档归属于哪个文档组,(把鼠标放在文件的ID上就可以看见它的名字). 要在阻力添加/移除一个用户,就直接编辑文档。'; $_lang["access_permissions_user_groups"] = '用户组'; $_lang["access_permissions_user_message"] = 'Select which User Groups this user belongs to:'; +$_lang["access_permissions_user_group_locked"] = '以灰色显示的组不在您的成员范围内。只有管理员或拥有"管理文档和用户组"权限的管理者才能更改它们,因此此处仅供查看。'; $_lang["access_permissions_users_in_group"] = '在本组的用户: '; $_lang["access_permissions_users_tab"] = '你可以在这里查看建立了哪些用户组。你也可以建立/重命名/删除组。可以查看某个用户归属于哪些组。 要在组里添加/移除一个用户,就直接编辑用户。管理员 (users who have been assigned the role with ID 1)永远都拥有查看操作文件的权利,因此他不需要归属于任何组都可以。'; $_lang["account_email"] = 'Account email'; diff --git a/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php b/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php index d801c58d43..ec54ca1b16 100644 --- a/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php +++ b/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php @@ -84,6 +84,18 @@ function processorSource(string $file): string ->and($source)->not->toContain("\UserManager::setGroups(['id' => \$user->getKey(), 'groups' => \$userData['user_groups']])"); }); +it('shows group checkboxes outside the actor reach as read-only in the user form', function () { + $source = (string) file_get_contents( + dirname(__DIR__, 3) . '/../manager/actions/mutate_web_user.dynamic.php' + ); + + // the form derives the same reach the save guard uses, and disables what the actor cannot change + expect($source)->toContain("hasPermission('manage_groups')") + ->and($source)->toContain('$canChangeGroup') + ->and($source)->toContain("' disabled=\"disabled\"'") + ->and($source)->toContain('access_permissions_user_group_locked'); +}); + it('checks the matching permission in the delete processors', function (string $file, string $permission, string $gone) { $source = processorSource($file); diff --git a/manager/actions/mutate_web_user.dynamic.php b/manager/actions/mutate_web_user.dynamic.php index 74a575b5a5..172d3e84a4 100755 --- a/manager/actions/mutate_web_user.dynamic.php +++ b/manager/actions/mutate_web_user.dynamic.php @@ -1082,8 +1082,27 @@ function evoRenderTvImageCheck(a) {

orderBy('name')->get(); + // Mirror the save guard (UserGroupGuard): without manage_groups a manager may only + // change the groups it belongs to itself, so the rest are shown read-only instead of + // as live checkboxes whose edits the server would silently drop. + $canManageAllGroups = (bool) $modx->hasPermission('manage_groups'); + $actorGroups = $canManageAllGroups + ? [] + : \EvolutionCMS\Models\MemberGroup::query() + ->where('member', (int) ($_SESSION['mgrInternalKey'] ?? 0)) + ->pluck('user_group') + ->map(static function ($group) { return (int) $group; }) + ->all(); + $hasLockedGroup = false; foreach ($webgroupnames->toArray() as $row) { - echo '
'; + $canChangeGroup = $canManageAllGroups || in_array((int) $row['id'], $actorGroups, true); + if (!$canChangeGroup) { + $hasLockedGroup = true; + } + echo '' . e((string)$row['name']) . '
'; + } + if ($hasLockedGroup) { + echo '

' . e((string) $_lang['access_permissions_user_group_locked']) . '

'; } } ?> From b6689534837080f3736ba2db5bd33b036ca73c2c Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 15:06:53 +0100 Subject: [PATCH 31/50] del(seeders): drop the dead action_ok permission and its dangling role grants UserPermissionsTableSeeder defined a permission keyed 'action_ok' (lang_key 'role_actionok'), and UserRolesTableSeeder granted every default role 'role_actionok' - the lang_key, not the key. So the grant matched no permission, and nothing in the code ever checked hasPermission('action_ok'): the permission was dead on both ends. Remove the permission definition and the three grants. A new unit test parses the two seeders and pins the invariant the dangling grant broke: every granted permission is a defined key. Co-Authored-By: Claude Opus 4.8 --- .../seeders/UserPermissionsTableSeeder.php | 7 --- .../database/seeders/UserRolesTableSeeder.php | 3 - .../Install/SeederPermissionGrantsTest.php | 56 +++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 core/tests/Unit/Install/SeederPermissionGrantsTest.php diff --git a/core/database/seeders/UserPermissionsTableSeeder.php b/core/database/seeders/UserPermissionsTableSeeder.php index ab08808ab7..6bc12397fa 100644 --- a/core/database/seeders/UserPermissionsTableSeeder.php +++ b/core/database/seeders/UserPermissionsTableSeeder.php @@ -116,13 +116,6 @@ public function run(): void 'disabled' => 0, 'group_id' => 1 ], - [ - 'name' => 'View action completed screen', - 'lang_key' => 'role_actionok', - 'key' => 'action_ok', - 'disabled' => 1, - 'group_id' => 1 - ], [ 'name' => 'View error dialog', 'lang_key' => 'role_errors', diff --git a/core/database/seeders/UserRolesTableSeeder.php b/core/database/seeders/UserRolesTableSeeder.php index d20d3268ef..02190d25e0 100644 --- a/core/database/seeders/UserRolesTableSeeder.php +++ b/core/database/seeders/UserRolesTableSeeder.php @@ -81,7 +81,6 @@ public function run(): void ['permission' => 'home', 'role_id' => 1], ['permission' => 'widget_recent_info', 'role_id' => 1], ['permission' => 'help', 'role_id' => 1], - ['permission' => 'role_actionok', 'role_id' => 1], ['permission' => 'error_dialog', 'role_id' => 1], ['permission' => 'about', 'role_id' => 1], ['permission' => 'widget_online_info', 'role_id' => 1], @@ -152,7 +151,6 @@ public function run(): void ['permission' => 'home', 'role_id' => 2], ['permission' => 'widget_recent_info', 'role_id' => 2], ['permission' => 'help', 'role_id' => 2], - ['permission' => 'role_actionok', 'role_id' => 2], ['permission' => 'error_dialog', 'role_id' => 2], ['permission' => 'about', 'role_id' => 2], ['permission' => 'widget_online_info', 'role_id' => 2], @@ -187,7 +185,6 @@ public function run(): void ['permission' => 'home', 'role_id' => 3], ['permission' => 'widget_recent_info', 'role_id' => 3], ['permission' => 'help', 'role_id' => 3], - ['permission' => 'role_actionok', 'role_id' => 3], ['permission' => 'error_dialog', 'role_id' => 3], ['permission' => 'about', 'role_id' => 3], ['permission' => 'widget_online_info', 'role_id' => 3], diff --git a/core/tests/Unit/Install/SeederPermissionGrantsTest.php b/core/tests/Unit/Install/SeederPermissionGrantsTest.php new file mode 100644 index 0000000000..07c0efcaf7 --- /dev/null +++ b/core/tests/Unit/Install/SeederPermissionGrantsTest.php @@ -0,0 +1,56 @@ +\\s*'([^']+)'/", + seederSource('UserPermissionsTableSeeder.php'), + $matches + ); + + return array_values(array_unique($matches[1])); +} + +/** @return string[] */ +function grantedPermissionKeys(): array +{ + preg_match_all( + "/'permission'\\s*=>\\s*'([^']+)'/", + seederSource('UserRolesTableSeeder.php'), + $matches + ); + + return array_values(array_unique($matches[1])); +} + +it('grants only permissions that are actually defined', function () { + $undefined = array_diff(grantedPermissionKeys(), seededPermissionKeys()); + + expect($undefined)->toBe([]); +}); + +it('no longer defines or grants the dead action_ok permission', function () { + expect(seededPermissionKeys())->not->toContain('action_ok') + ->and(grantedPermissionKeys())->not->toContain('role_actionok') + ->and(grantedPermissionKeys())->not->toContain('action_ok'); +}); From d174231d5622957f5d8d0efcc67a2c5f39c0f9e7 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 15:19:07 +0100 Subject: [PATCH 32/50] fix(manager): contain a per-user filemanager_path within the system root filemanager_path is an ordinary user setting, merged over the site-wide value in Core::getUserSettings(). The classic file manager then browses, reads and writes under getConfig('filemanager_path') - the merged value - while it keys its group ACL on the site-wide root (configGlobal, fileManagerSiteAclRoot()). So a per-user override pointing outside the system root widens the file manager past the intended root while the ACL stays on the narrower one. Clamp the merged value: a per-user filemanager_path may narrow the root to a subfolder of the site-wide one, but an override that does not resolve or escapes the system root is dropped back to the system value. The decision is a new pure FileManagerAccess::userRootAllowed() so it is unit-tested directly. Co-Authored-By: Claude Opus 4.8 --- core/src/Core.php | 15 ++++++ core/src/Support/FileManagerAccess.php | 19 ++++++++ .../UserFileManagerPathContainmentTest.php | 48 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 core/tests/Unit/Security/UserFileManagerPathContainmentTest.php diff --git a/core/src/Core.php b/core/src/Core.php index 8413bc9c8a..75cc963108 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -6947,6 +6947,21 @@ public function getUserSettings() str_replace('[(base_path)]', EVO_BASE_PATH, $this->getConfig('rb_base_dir')) ); + // A per-user filemanager_path may narrow the file manager root to a subfolder of the + // site-wide one, but never widen it past that root: the per-user value is attacker-reachable + // (it is an ordinary user setting), while the file manager keys its group ACL on the + // site-wide root (configGlobal). Drop an override that escapes the system root. + if (isset($this->configGlobal['filemanager_path'])) { + $systemPath = str_replace('[(base_path)]', EVO_BASE_PATH, (string) $this->configGlobal['filemanager_path']); + $allowed = Support\FileManagerAccess::userRootAllowed( + realpath($systemPath), + realpath((string) $this->getConfig('filemanager_path')) + ); + if (!$allowed) { + $this->setConfig('filemanager_path', $systemPath); + } + } + return $usrSettings; } diff --git a/core/src/Support/FileManagerAccess.php b/core/src/Support/FileManagerAccess.php index 90a2618f30..9acb780328 100644 --- a/core/src/Support/FileManagerAccess.php +++ b/core/src/Support/FileManagerAccess.php @@ -33,6 +33,25 @@ public static function isWithin(string $root, string $path): bool return $path === $root || strncmp($path, $root . '/', strlen($root) + 1) === 0; } + /** + * Whether a per-user file manager root may be used instead of the site-wide one. + * + * A per-user filemanager_path may only narrow the root to a subfolder of the system root, + * never widen it past that root. Both arguments are realpath() results, so a path that does + * not resolve is passed as false and rejected: callers keep the system root in that case. + * + * @param string|false $systemRoot realpath of the site-wide filemanager_path + * @param string|false $userRoot realpath of the merged (possibly per-user) filemanager_path + */ + public static function userRootAllowed($systemRoot, $userRoot): bool + { + if ($systemRoot === false || $userRoot === false) { + return false; + } + + return self::isWithin($systemRoot, $userRoot); + } + private static function caseInsensitiveFileSystem(): bool { return PHP_OS_FAMILY === 'Windows'; diff --git a/core/tests/Unit/Security/UserFileManagerPathContainmentTest.php b/core/tests/Unit/Security/UserFileManagerPathContainmentTest.php new file mode 100644 index 0000000000..c299b62281 --- /dev/null +++ b/core/tests/Unit/Security/UserFileManagerPathContainmentTest.php @@ -0,0 +1,48 @@ +toBeTrue() + ->and(FileManagerAccess::userRootAllowed('/var/www/site/assets', '/var/www/site/assets'))->toBeTrue(); +}); + +test('a per-user root outside the system root is refused', function () { + expect(FileManagerAccess::userRootAllowed('/var/www/site/assets', '/var/www/site'))->toBeFalse() + ->and(FileManagerAccess::userRootAllowed('/var/www/site/assets', '/etc'))->toBeFalse() + // the sibling-prefix trap: assets-private is not under assets + ->and(FileManagerAccess::userRootAllowed('/var/www/site/assets', '/var/www/site/assets-private'))->toBeFalse(); +}); + +test('an unresolved root on either side is refused', function () { + expect(FileManagerAccess::userRootAllowed(false, '/var/www/site/assets/alice'))->toBeFalse() + ->and(FileManagerAccess::userRootAllowed('/var/www/site/assets', false))->toBeFalse() + ->and(FileManagerAccess::userRootAllowed(false, false))->toBeFalse(); +}); + +it('drops a per-user filemanager_path that escapes the system root in getUserSettings', function () { + $source = (string) file_get_contents(dirname(__DIR__, 3) . '/src/Core.php'); + + $start = strpos($source, 'public function getUserSettings'); + expect($start)->not->toBeFalse(); + $body = substr($source, $start, strpos($source, 'return $usrSettings;', $start) - $start); + + expect($body)->toContain('Support\FileManagerAccess::userRootAllowed(') + ->and($body)->toContain("configGlobal['filemanager_path']") + ->and($body)->toContain("\$this->setConfig('filemanager_path', \$systemPath);"); +}); From 9947572e20758820a2609c31acb7a33a11dac3fb Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 16:07:00 +0100 Subject: [PATCH 33/50] add(manager): gate the Security settings tab behind a security_settings permission The Security tab of the system settings holds the access-control toggles (use_udperms, udperms_allowroot), the server-side code-execution toggles (allow_eval, enable_bindings, safe_functions_at_eval) and the login/captcha hardening. All of it was reachable with the generic `settings` right, which the default Publisher role holds - so a non-administrator could switch off the document ACL or enable eval. Add a dedicated `security_settings` permission, granted to the administrator role only. SecuritySettings::GATED is the single source of truth for the tab's keys: the settings form hides the tab without the permission, and save_settings skips those keys so a crafted POST cannot set them either. A test cross-checks GATED against the keys the Security tab actually renders, so the two cannot drift. Co-Authored-By: Claude Opus 4.8 --- .../seeders/UserPermissionsTableSeeder.php | 7 ++ .../database/seeders/UserRolesTableSeeder.php | 1 + core/lang/az/global.php | 1 + core/lang/be/global.php | 1 + core/lang/bg/global.php | 1 + core/lang/cs/global.php | 1 + core/lang/da/global.php | 1 + core/lang/de/global.php | 1 + core/lang/en/global.php | 1 + core/lang/es/global.php | 1 + core/lang/et/global.php | 1 + core/lang/fa/global.php | 1 + core/lang/fi/global.php | 1 + core/lang/fr/global.php | 1 + core/lang/he/global.php | 1 + core/lang/hy/global.php | 1 + core/lang/it/global.php | 1 + core/lang/ja/global.php | 1 + core/lang/ka/global.php | 1 + core/lang/kk/global.php | 1 + core/lang/ky/global.php | 1 + core/lang/lt/global.php | 1 + core/lang/lv/global.php | 1 + core/lang/nl/global.php | 1 + core/lang/nn/global.php | 1 + core/lang/pl/global.php | 1 + core/lang/pt/global.php | 1 + core/lang/ro/global.php | 1 + core/lang/sk/global.php | 1 + core/lang/sv/global.php | 1 + core/lang/tg/global.php | 1 + core/lang/tk/global.php | 1 + core/lang/uk/global.php | 1 + core/lang/uz/global.php | 1 + core/lang/zh/global.php | 1 + core/src/Controllers/SystemSettings.php | 1 + core/src/Support/SecuritySettings.php | 50 ++++++++++++ .../SecuritySettingsPermissionTest.php | 79 +++++++++++++++++++ .../processors/save_settings.processor.php | 8 ++ manager/views/page/system_settings.blade.php | 4 +- 40 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 core/src/Support/SecuritySettings.php create mode 100644 core/tests/Unit/Security/SecuritySettingsPermissionTest.php diff --git a/core/database/seeders/UserPermissionsTableSeeder.php b/core/database/seeders/UserPermissionsTableSeeder.php index ab08808ab7..f96e0094e9 100644 --- a/core/database/seeders/UserPermissionsTableSeeder.php +++ b/core/database/seeders/UserPermissionsTableSeeder.php @@ -549,6 +549,13 @@ public function run(): void 'disabled' => 0, 'group_id' => 14 ], + [ + 'name' => 'Change security settings', + 'key' => 'security_settings', + 'lang_key' => 'role_security_settings', + 'disabled' => 0, + 'group_id' => 14 + ], [ 'name' => 'Use the Backup Manager', 'key' => 'bk_manager', diff --git a/core/database/seeders/UserRolesTableSeeder.php b/core/database/seeders/UserRolesTableSeeder.php index d20d3268ef..9956bd9c4d 100644 --- a/core/database/seeders/UserRolesTableSeeder.php +++ b/core/database/seeders/UserRolesTableSeeder.php @@ -140,6 +140,7 @@ public function run(): void ['permission' => 'delete_eventlog', 'role_id' => 1], ['permission' => 'logs', 'role_id' => 1], ['permission' => 'settings', 'role_id' => 1], + ['permission' => 'security_settings', 'role_id' => 1], ['permission' => 'bk_manager', 'role_id' => 1], ['permission' => 'remove_locks', 'role_id' => 1], ['permission' => 'display_locks', 'role_id' => 1], diff --git a/core/lang/az/global.php b/core/lang/az/global.php index 26351ef9e8..549b9135a4 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -343,6 +343,7 @@ $_lang["role_edit_plugin"] = 'Plaqin redaktə et'; $_lang["role_edit_role"] = 'Rol redaktə et'; $_lang["role_edit_settings"] = 'Sayt ayarlarını dəyiş'; +$_lang["role_security_settings"] = 'Təhlükəsizlik ayarlarını dəyiş'; $_lang["role_edit_snippet"] = 'Snippet redaktə et'; $_lang["role_edit_template"] = 'Şablon redaktə et'; $_lang["role_edit_user"] = 'İstifadəçi redaktə et'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index e0f041c3c9..f323cd79ed 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -331,6 +331,7 @@ $_lang["role_edit_plugin"] = 'Рэдагаваць плагін'; $_lang["role_edit_role"] = 'Рэдагаваць ролі'; $_lang["role_edit_settings"] = 'Змяніць налады сайта'; +$_lang["role_security_settings"] = 'Змяніць налады бяспекі'; $_lang["role_edit_snippet"] = 'Рэдагаваць сніпеты'; $_lang["role_edit_template"] = 'Рэдагаваць шаблоны сайтаў'; $_lang["role_edit_user"] = 'Рэдагаваць карыстальнікаў'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 3d3cb3875c..ed71ed37fe 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -793,6 +793,7 @@ $_lang["role_edit_plugin"] = 'Редактиране на плъгини'; $_lang["role_edit_role"] = 'Редактиране на роли'; $_lang["role_edit_settings"] = 'Промяна на настройките на сайта'; +$_lang["role_security_settings"] = 'Промяна на настройките за сигурност'; $_lang["role_edit_snippet"] = 'Редактиране на снипети'; $_lang["role_edit_template"] = 'Редактиране на шаблоните на сайта'; $_lang["role_edit_user"] = 'Редактиране на потребители'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 251bb590b6..a8258b776d 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -797,6 +797,7 @@ $_lang["role_edit_plugin"] = 'Upravit pluginy'; $_lang["role_edit_role"] = 'Upravit oprávnění'; $_lang["role_edit_settings"] = 'Změnit nastavení stránek'; +$_lang["role_security_settings"] = 'Změnit nastavení zabezpečení'; $_lang["role_edit_snippet"] = 'Upravit snippety'; $_lang["role_edit_template"] = 'Upravit šablony stránek'; $_lang["role_edit_user"] = 'Upravit uživatele'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index ab3f308077..04eb956440 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -794,6 +794,7 @@ $_lang["role_edit_plugin"] = 'Redigere plugins'; $_lang["role_edit_role"] = 'Redigere roller'; $_lang["role_edit_settings"] = 'Skifte websitets indstillinger'; +$_lang["role_security_settings"] = 'Skifte sikkerhedsindstillinger'; $_lang["role_edit_snippet"] = 'Rediger snippets'; $_lang["role_edit_template"] = 'Redigere ressource skabeloner'; $_lang["role_edit_user"] = 'Redigere brugere'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index 196daa3880..4dcecfe997 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -842,6 +842,7 @@ $_lang["role_edit_plugin"] = 'Plugins bearbeiten'; $_lang["role_edit_role"] = 'Rollen bearbeiten'; $_lang["role_edit_settings"] = 'Konfiguration bearbeiten'; +$_lang["role_security_settings"] = 'Sicherheitseinstellungen bearbeiten'; $_lang["role_edit_snippet"] = 'Snippets bearbeiten'; $_lang["role_edit_template"] = 'Templates bearbeiten'; $_lang["role_edit_user"] = 'Manager-Benutzer bearbeiten'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 96c6eee69b..c29b0a66c5 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Edit Plugins'; $_lang["role_edit_role"] = 'Edit roles'; $_lang["role_edit_settings"] = 'Change site settings'; +$_lang["role_security_settings"] = 'Change security settings'; $_lang["role_edit_snippet"] = 'Edit Snippets'; $_lang["role_edit_template"] = 'Edit site Templates'; $_lang["role_edit_user"] = 'Edit users'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index aff1897d3b..7ec8b4635f 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -844,6 +844,7 @@ $_lang["role_edit_plugin"] = 'Editar plugins'; $_lang["role_edit_role"] = 'Editar roles'; $_lang["role_edit_settings"] = 'Cambiar la configuración del sitio'; +$_lang["role_security_settings"] = 'Cambiar la configuración de seguridad'; $_lang["role_edit_snippet"] = 'Editar snippets'; $_lang["role_edit_template"] = 'Editar templates del sitio'; $_lang["role_edit_user"] = 'Editar usuarios'; diff --git a/core/lang/et/global.php b/core/lang/et/global.php index 519b563b4d..adad03972e 100644 --- a/core/lang/et/global.php +++ b/core/lang/et/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Muuda pluginaid'; $_lang["role_edit_role"] = 'Muuda rolle'; $_lang["role_edit_settings"] = 'Muuda saidi seadeid'; +$_lang["role_security_settings"] = 'Muuda turvaseadeid'; $_lang["role_edit_snippet"] = 'Muuda koodilõike'; $_lang["role_edit_template"] = 'Muuda saidimalle'; $_lang["role_edit_user"] = 'Muuda kasutajaid'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 4cc02c75c6..86085d75b6 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -794,6 +794,7 @@ $_lang["role_edit_plugin"] = 'ویرایش پلاگین'; $_lang["role_edit_role"] = 'ئیرایش نقش ها'; $_lang["role_edit_settings"] = 'اعمال تغییر در تنظیمات وبگاه'; +$_lang["role_security_settings"] = 'اعمال تغییر در تنظیمات امنیتی'; $_lang["role_edit_snippet"] = 'ویرایش اسنیپتها'; $_lang["role_edit_template"] = 'ویرایش پوسته یا قالب وبگاه'; $_lang["role_edit_user"] = 'ویرایش کاربران'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index a8c64f9a8c..0981ca1099 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -793,6 +793,7 @@ $_lang["role_edit_plugin"] = 'Muokkaa liitännäisiä'; $_lang["role_edit_role"] = 'Muokkaa roolia'; $_lang["role_edit_settings"] = 'Muuta sivuston asetuksia'; +$_lang["role_security_settings"] = 'Muuta tietoturva-asetuksia'; $_lang["role_edit_snippet"] = 'Muokkaa PHP-palasta'; $_lang["role_edit_template"] = 'Muokkaa sivupohjaa'; $_lang["role_edit_user"] = 'Muokkaa käyttäjää'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index ed9e413690..23f2f6008a 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -796,6 +796,7 @@ $_lang["role_edit_plugin"] = 'Modifier les Plugins'; $_lang["role_edit_role"] = 'Modifier les rôles'; $_lang["role_edit_settings"] = 'Modifier la configuration du site'; +$_lang["role_security_settings"] = 'Modifier la configuration de sécurité'; $_lang["role_edit_snippet"] = 'Modifier les Snippets'; $_lang["role_edit_template"] = 'Modifier les Modèles'; $_lang["role_edit_user"] = 'Modifier les Utilisateurs'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index c65b26abdb..c87160ec36 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -794,6 +794,7 @@ $_lang["role_edit_plugin"] = 'ערוך התקנים'; $_lang["role_edit_role"] = 'ערוך תפקיד'; $_lang["role_edit_settings"] = 'שנה הגדרות אתר'; +$_lang["role_security_settings"] = 'שנה הגדרות אבטחה'; $_lang["role_edit_snippet"] = 'ערוך קטעי קוד'; $_lang["role_edit_template"] = 'ערוך תבניות עיצוב'; $_lang["role_edit_user"] = 'ערוך משתמשים'; diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php index 5a678f4cce..ebd1847e99 100644 --- a/core/lang/hy/global.php +++ b/core/lang/hy/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Խմբագրել փլագինները'; $_lang["role_edit_role"] = 'Խմբագրել դերերը'; $_lang["role_edit_settings"] = 'Փոխել կայքի կարգավորումները'; +$_lang["role_security_settings"] = 'Փոխել անվտանգության կարգավորումները'; $_lang["role_edit_snippet"] = 'Խմբագրել սնիփեթները'; $_lang["role_edit_template"] = 'Խմբագրել կայքի ձևանմուշները'; $_lang["role_edit_user"] = 'Խմբագրել օգտատերերին'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index 62d31ff99c..e432dccb4f 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -844,6 +844,7 @@ $_lang["role_edit_plugin"] = 'Modifica Plugin'; $_lang["role_edit_role"] = 'Modifica ruoli'; $_lang["role_edit_settings"] = 'Cambia le impostazioni del sito'; +$_lang["role_security_settings"] = 'Cambia le impostazioni di sicurezza'; $_lang["role_edit_snippet"] = 'Modifica Snippet'; $_lang["role_edit_template"] = 'Modifica Template del sito'; $_lang["role_edit_user"] = 'Modifica utenti'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 9f4787ee04..1680c45eb0 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -873,6 +873,7 @@ $_lang["role_edit_plugin"] = 'プラグイン編集画面を開く'; $_lang["role_edit_role"] = 'ロール編集画面を開く'; $_lang["role_edit_settings"] = 'グローバル設定を変更'; +$_lang["role_security_settings"] = 'セキュリティ設定を変更'; $_lang["role_edit_snippet"] = 'スニペット編集画面を開く'; $_lang["role_edit_template"] = 'テンプレート編集画面を開く'; $_lang["role_edit_user"] = 'ユーザー編集画面を開く'; diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php index f59adc9cce..6dfa56efd8 100644 --- a/core/lang/ka/global.php +++ b/core/lang/ka/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'პლაგინების რედაქტირება'; $_lang["role_edit_role"] = 'როლების რედაქტირება'; $_lang["role_edit_settings"] = 'საიტის პარამეტრების შეცვლა'; +$_lang["role_security_settings"] = 'უსაფრთხოების პარამეტრების შეცვლა'; $_lang["role_edit_snippet"] = 'სნიპეტების რედაქტირება'; $_lang["role_edit_template"] = 'საიტის შაბლონების რედაქტირება'; $_lang["role_edit_user"] = 'მომხმარებლების რედაქტირება'; diff --git a/core/lang/kk/global.php b/core/lang/kk/global.php index fe39072d02..1b3dffec60 100644 --- a/core/lang/kk/global.php +++ b/core/lang/kk/global.php @@ -889,6 +889,7 @@ $_lang["role_edit_plugin"] = 'Плагиндерді өңдеу'; $_lang["role_edit_role"] = 'Рөлдерді өңдеу'; $_lang["role_edit_settings"] = 'Сайт баптауларын өзгерту'; +$_lang["role_security_settings"] = 'Қауіпсіздік баптауларын өзгерту'; $_lang["role_edit_snippet"] = 'Сниппеттерді өңдеу'; $_lang["role_edit_template"] = 'Сайт үлгілерін өңдеу'; $_lang["role_edit_user"] = 'Пайдаланушыларды өңдеу'; diff --git a/core/lang/ky/global.php b/core/lang/ky/global.php index d93a1aeaaa..b65dd1fc98 100644 --- a/core/lang/ky/global.php +++ b/core/lang/ky/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Плагиндерди түзөтүү'; $_lang["role_edit_role"] = 'Ролдорду түзөтүү'; $_lang["role_edit_settings"] = 'Сайттын жөндөөлөрүн өзгөртүү'; +$_lang["role_security_settings"] = 'Коопсуздук жөндөөлөрүн өзгөртүү'; $_lang["role_edit_snippet"] = 'Сниппеттерди түзөтүү'; $_lang["role_edit_template"] = 'Сайттын калыптарын түзөтүү'; $_lang["role_edit_user"] = 'Колдонуучуларды түзөтүү'; diff --git a/core/lang/lt/global.php b/core/lang/lt/global.php index 3e899fe468..7e380ea7f5 100644 --- a/core/lang/lt/global.php +++ b/core/lang/lt/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Redaguoti papildinius'; $_lang["role_edit_role"] = 'Redaguoti vaidmenis'; $_lang["role_edit_settings"] = 'Keisti svetainės nuostatas'; +$_lang["role_security_settings"] = 'Keisti saugumo nuostatas'; $_lang["role_edit_snippet"] = 'Redaguoti kodo fragmentus'; $_lang["role_edit_template"] = 'Redaguoti svetainės šablonus'; $_lang["role_edit_user"] = 'Redaguoti naudotojus'; diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php index edeec426bd..c9d360b55f 100644 --- a/core/lang/lv/global.php +++ b/core/lang/lv/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Rediģēt spraudņus'; $_lang["role_edit_role"] = 'Rediģēt lomas'; $_lang["role_edit_settings"] = 'Mainīt vietnes iestatījumus'; +$_lang["role_security_settings"] = 'Mainīt drošības iestatījumus'; $_lang["role_edit_snippet"] = 'Rediģēt koda fragmentus'; $_lang["role_edit_template"] = 'Rediģēt vietnes veidnes'; $_lang["role_edit_user"] = 'Rediģēt lietotājus'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 0c09b6214f..e4cfb7f33c 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -832,6 +832,7 @@ $_lang["role_edit_plugin"] = 'Plug-ins bewerken'; $_lang["role_edit_role"] = 'Rollen bewerken'; $_lang["role_edit_settings"] = 'Website instellingen veranderen'; +$_lang["role_security_settings"] = 'Beveiligingsinstellingen veranderen'; $_lang["role_edit_snippet"] = 'Snippets bewerken'; $_lang["role_edit_template"] = 'Website Templates bewerken'; $_lang["role_edit_user"] = 'Bewerk manager gebruikers'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 29c85219be..1571be8c00 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -793,6 +793,7 @@ $_lang["role_edit_plugin"] = 'Rediger pluginer'; $_lang["role_edit_role"] = 'Rediger roller'; $_lang["role_edit_settings"] = 'Endre webplassens instillinger'; +$_lang["role_security_settings"] = 'Endre sikkerheitsinstillingar'; $_lang["role_edit_snippet"] = 'Rediger kodesnutter'; $_lang["role_edit_template"] = 'Rediger maler'; $_lang["role_edit_user"] = 'Rediger brukere'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index c9a169b2bf..dbd3788199 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -845,6 +845,7 @@ $_lang["role_edit_plugin"] = 'Edytuj wtyczki'; $_lang["role_edit_role"] = 'Edytowanie ról'; $_lang["role_edit_settings"] = 'Zmiana ustawień witryny'; +$_lang["role_security_settings"] = 'Zmiana ustawień zabezpieczeń'; $_lang["role_edit_snippet"] = 'Edytowanie snippetów'; $_lang["role_edit_template"] = 'Edytowanie szablonów'; $_lang["role_edit_user"] = 'Edytuj użytkowników Menedżera'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index d1bbf03eaa..ab522c4200 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -793,6 +793,7 @@ $_lang["role_edit_plugin"] = 'Editar Plugins'; $_lang["role_edit_role"] = 'Editar Perfis'; $_lang["role_edit_settings"] = 'Alterar configurações do site'; +$_lang["role_security_settings"] = 'Alterar configurações de segurança'; $_lang["role_edit_snippet"] = 'Editar Snippets'; $_lang["role_edit_template"] = 'Editar Templates'; $_lang["role_edit_user"] = 'Editar utilizadores'; diff --git a/core/lang/ro/global.php b/core/lang/ro/global.php index 3035faaf3e..e81a81a2ca 100644 --- a/core/lang/ro/global.php +++ b/core/lang/ro/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Editarea pluginurilor'; $_lang["role_edit_role"] = 'Editarea rolurilor'; $_lang["role_edit_settings"] = 'Modificarea setărilor site-ului'; +$_lang["role_security_settings"] = 'Modificarea setărilor de securitate'; $_lang["role_edit_snippet"] = 'Editarea fragmentelor de cod'; $_lang["role_edit_template"] = 'Editarea șabloanelor site-ului'; $_lang["role_edit_user"] = 'Editarea utilizatorilor'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 002a8bbe48..f30fe1e9a2 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -864,6 +864,7 @@ $_lang["role_edit_plugin"] = 'Úprava pluginov'; $_lang["role_edit_role"] = 'Úprava rolí'; $_lang["role_edit_settings"] = 'Zmena konfigurácie webu'; +$_lang["role_security_settings"] = 'Zmena nastavení zabezpečenia'; $_lang["role_edit_snippet"] = 'Úprava snippetov'; $_lang["role_edit_template"] = 'Úprava šablón'; $_lang["role_edit_user"] = 'Úprava používateľov'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index de6aada43b..7d98e55aef 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -794,6 +794,7 @@ $_lang["role_edit_plugin"] = 'Redigera plugins'; $_lang["role_edit_role"] = 'Redigera roller'; $_lang["role_edit_settings"] = 'Ändra webbplatsens inställningar'; +$_lang["role_security_settings"] = 'Ändra säkerhetsinställningar'; $_lang["role_edit_snippet"] = 'Redigera snippets'; $_lang["role_edit_template"] = 'Redigera mallar'; $_lang["role_edit_user"] = 'Redigera användare'; diff --git a/core/lang/tg/global.php b/core/lang/tg/global.php index 87bfbc7db0..25d8152dc0 100644 --- a/core/lang/tg/global.php +++ b/core/lang/tg/global.php @@ -863,6 +863,7 @@ $_lang["role_edit_plugin"] = 'Таҳрири плагинҳо'; $_lang["role_edit_role"] = 'Таҳрири нақшҳо'; $_lang["role_edit_settings"] = 'Тағйири танзимоти сомона'; +$_lang["role_security_settings"] = 'Тағйири танзимоти амният'; $_lang["role_edit_snippet"] = 'Таҳрири сниппетҳо'; $_lang["role_edit_template"] = 'Таҳрири қолабҳои сомона'; $_lang["role_edit_user"] = 'Таҳрири корбарон'; diff --git a/core/lang/tk/global.php b/core/lang/tk/global.php index 37c8a236a4..e542e764b8 100644 --- a/core/lang/tk/global.php +++ b/core/lang/tk/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Plaginleri redaktirlemek'; $_lang["role_edit_role"] = 'Rollary redaktirlemek'; $_lang["role_edit_settings"] = 'Saýtyň sazlamalaryny üýtgetmek'; +$_lang["role_security_settings"] = 'Howpsuzlyk sazlamalaryny üýtgetmek'; $_lang["role_edit_snippet"] = 'Snippetleri redaktirlemek'; $_lang["role_edit_template"] = 'Saýtyň şablonlaryny redaktirlemek'; $_lang["role_edit_user"] = 'Ulanyjylary redaktirlemek'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 9418309233..a7c18b1184 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -871,6 +871,7 @@ $_lang["role_edit_plugin"] = 'Редагування плагінів'; $_lang["role_edit_role"] = 'Редагування ролей'; $_lang["role_edit_settings"] = 'Змінювати конфігурацію сайта'; +$_lang["role_security_settings"] = 'Змінювати налаштування безпеки'; $_lang["role_edit_snippet"] = 'Редагування сниппетов'; $_lang["role_edit_template"] = 'Редагування шаблонов'; $_lang["role_edit_user"] = 'Редагування пользователей'; diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php index 7e00d7c075..27a81ae664 100644 --- a/core/lang/uz/global.php +++ b/core/lang/uz/global.php @@ -868,6 +868,7 @@ $_lang["role_edit_plugin"] = 'Plaginlarni tahrirlash'; $_lang["role_edit_role"] = 'Rollarni tahrirlash'; $_lang["role_edit_settings"] = 'Sayt sozlamalarini o‘zgartirish'; +$_lang["role_security_settings"] = 'Xavfsizlik sozlamalarini o‘zgartirish'; $_lang["role_edit_snippet"] = 'Kod bo‘laklarini tahrirlash'; $_lang["role_edit_template"] = 'Sayt shablonlarini tahrirlash'; $_lang["role_edit_user"] = 'Foydalanuvchilarni tahrirlash'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 91448fb180..f36499c4ad 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -794,6 +794,7 @@ $_lang["role_edit_plugin"] = '编辑插件'; $_lang["role_edit_role"] = '编辑角色'; $_lang["role_edit_settings"] = '改变站点设置'; +$_lang["role_security_settings"] = '改变安全设置'; $_lang["role_edit_snippet"] = '编辑切片snippets'; $_lang["role_edit_template"] = '编辑站点模板'; $_lang["role_edit_user"] = '编辑用户'; diff --git a/core/src/Controllers/SystemSettings.php b/core/src/Controllers/SystemSettings.php index ee90bfcf9e..ceadfe0d33 100644 --- a/core/src/Controllers/SystemSettings.php +++ b/core/src/Controllers/SystemSettings.php @@ -66,6 +66,7 @@ public function getParameters(array $params = []): array 'gdAvailable' => $this->parameterCheckGD(), 'settings' => $this->parameterSettings(), 'disabledSettings' => $this->disabledSettings(), + 'canEditSecuritySettings' => $this->managerTheme->getCore()->hasPermission('security_settings') === 1, 'displayStyle' => ($_SESSION['browser'] === 'modern') ? 'table-row' : 'block', 'fileBrowsers' => $this->parameterFileBrowsers(), 'themes' => $this->parameterThemes(), diff --git a/core/src/Support/SecuritySettings.php b/core/src/Support/SecuritySettings.php new file mode 100644 index 0000000000..02eb5825f5 --- /dev/null +++ b/core/src/Support/SecuritySettings.php @@ -0,0 +1,50 @@ +\\s*'([a-z0-9_]+)'/", + b11Source('manager/views/page/system_settings/security.blade.php'), + $matches + ); + + return array_values(array_unique($matches[1])); +} + +test('the gated list matches exactly the keys the Security tab renders', function () { + $tab = securityTabKeys(); + $gated = SecuritySettings::GATED; + sort($tab); + sort($gated); + + expect($gated)->toBe($tab); +}); + +test('isGated answers for the decisive keys', function () { + expect(SecuritySettings::isGated('use_udperms'))->toBeTrue() + ->and(SecuritySettings::isGated('allow_eval'))->toBeTrue() + ->and(SecuritySettings::isGated('safe_functions_at_eval'))->toBeTrue() + // an ordinary setting is not gated + ->and(SecuritySettings::isGated('site_name'))->toBeFalse(); +}); + +it('skips the gated keys in save_settings without the permission', function () { + $source = b11Source('manager/processors/save_settings.processor.php'); + + expect($source)->toContain("hasPermission('security_settings')") + ->and($source)->toContain('SecuritySettings::isGated($k)') + ->and($source)->toContain('continue;'); +}); + +it('hides the Security tab without the permission', function () { + $source = b11Source('manager/views/page/system_settings.blade.php'); + + expect($source)->toContain('@if($canEditSecuritySettings)') + ->and($source)->toContain("@include('manager::page.system_settings.security')"); +}); + +it('grants security_settings to the administrator role only', function () { + $source = b11Source('core/database/seeders/UserRolesTableSeeder.php'); + + preg_match_all( + "/'permission'\\s*=>\\s*'security_settings',\\s*'role_id'\\s*=>\\s*(\\d+)/", + $source, + $matches + ); + + expect($matches[1])->toBe(['1']); +}); diff --git a/manager/processors/save_settings.processor.php b/manager/processors/save_settings.processor.php index f43820abf5..1748788227 100755 --- a/manager/processors/save_settings.processor.php +++ b/manager/processors/save_settings.processor.php @@ -54,11 +54,19 @@ } } $data['sys_files_checksum'] = $modx->getManagerApi()->getSystemChecksum($data['check_files_onlogin']); + $mayEditSecurity = $modx->hasPermission('security_settings'); foreach ($data as $k => $v) { if (isset($defaultSettings[$k])) { continue; } + // The Security tab (ACL toggles, eval and login hardening) needs its own permission on top + // of `settings`; skip those keys without it so a crafted POST cannot set them. The form also + // hides the tab, so an ordinary save never carries them. + if (!$mayEditSecurity && \EvolutionCMS\Support\SecuritySettings::isGated($k)) { + continue; + } + switch ($k) { case 'settings_version': if ($modx->getVersionData('version') != $data['settings_version']) { diff --git a/manager/views/page/system_settings.blade.php b/manager/views/page/system_settings.blade.php index 4b7f8e1f1c..e623cccf34 100644 --- a/manager/views/page/system_settings.blade.php +++ b/manager/views/page/system_settings.blade.php @@ -37,7 +37,9 @@ @include('manager::page.system_settings.general') @include('manager::page.system_settings.friendly_urls') @include('manager::page.system_settings.interface') - @include('manager::page.system_settings.security') + @if($canEditSecuritySettings) + @include('manager::page.system_settings.security') + @endif @include('manager::page.system_settings.file_manager') @include('manager::page.system_settings.file_browser') @include('manager::page.system_settings.mail_templates') From dd4f2977a67e7378d454d7bf9424682f0a338c19 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 19:33:52 +0100 Subject: [PATCH 34/50] upd(acl): drop settings and bk_manager from the default Publisher preset The Publisher role shipped with `settings` and `bk_manager` by default. Both reach beyond a content role: - `bk_manager` lets the Backup manager download a full database dump, exposing every user's password hash and every secret stored in the settings table. - `settings` reaches the non-Security tabs that stay outside the `security_settings` gate - the File Manager tab (global filemanager_path and the upload allow-lists) and the Mail tab (SMTP). Neither is granted to Publisher on a fresh install any more. This is a new-installs-only change: the core baseline-repair seeder only inserts missing rows, so an existing site that already granted them keeps them. Both seeders that define the preset are updated so the baseline repair does not re-add the two grants after the install stub drops them, and the role description no longer claims site settings. Co-Authored-By: Claude Opus 4.8 --- .../database/seeders/UserRolesTableSeeder.php | 4 +- .../Install/PublisherDefaultPresetTest.php | 91 +++++++++++++++++++ .../seeds/install/UserRolesTableSeeder.php | 4 +- 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 core/tests/Unit/Install/PublisherDefaultPresetTest.php diff --git a/core/database/seeders/UserRolesTableSeeder.php b/core/database/seeders/UserRolesTableSeeder.php index d20d3268ef..cc32ba6857 100644 --- a/core/database/seeders/UserRolesTableSeeder.php +++ b/core/database/seeders/UserRolesTableSeeder.php @@ -71,7 +71,7 @@ public function run(): void [ 'id' => 3, 'name' => 'Publisher', - 'description' => 'Editor with expanded permissions including manage users, update Elements and site settings', + 'description' => 'Editor with expanded permissions including manage users and update Elements', ] ]); @@ -221,8 +221,6 @@ public function run(): void ['permission' => 'save_user', 'role_id' => 3], ['permission' => 'delete_user', 'role_id' => 3], ['permission' => 'logs', 'role_id' => 3], - ['permission' => 'settings', 'role_id' => 3], - ['permission' => 'bk_manager', 'role_id' => 3], ['permission' => 'remove_locks', 'role_id' => 3], ['permission' => 'display_locks', 'role_id' => 3], ['permission' => 'access_permissions', 'role_id' => 3], diff --git a/core/tests/Unit/Install/PublisherDefaultPresetTest.php b/core/tests/Unit/Install/PublisherDefaultPresetTest.php new file mode 100644 index 0000000000..ab556d5fc7 --- /dev/null +++ b/core/tests/Unit/Install/PublisherDefaultPresetTest.php @@ -0,0 +1,91 @@ + role id => sorted, unique permission keys it is granted + */ +function presetGrants(string $relative): array +{ + preg_match_all( + "/'permission'\\s*=>\\s*'([a-z0-9_]+)',\\s*'role_id'\\s*=>\\s*(\\d+)/", + presetSource($relative), + $matches, + PREG_SET_ORDER + ); + + $byRole = []; + foreach ($matches as $match) { + $byRole[(int) $match[2]][] = $match[1]; + } + foreach ($byRole as &$permissions) { + $permissions = array_values(array_unique($permissions)); + sort($permissions); + } + + return $byRole; +} + +const PRESET_SEEDERS = [ + 'install/stubs/seeds/install/UserRolesTableSeeder.php', + 'core/database/seeders/UserRolesTableSeeder.php', +]; + +it('does not grant the Publisher role settings or bk_manager', function (string $seeder) { + $publisher = presetGrants($seeder)[3] ?? []; + + expect($publisher)->not->toContain('settings') + ->and($publisher)->not->toContain('bk_manager') + // the role is still a working content role, not gutted + ->and($publisher)->toContain('save_document') + ->and($publisher)->toContain('delete_chunk') + ->and($publisher)->toContain('save_user'); +})->with(PRESET_SEEDERS); + +it('keeps settings and bk_manager with the administrator role', function (string $seeder) { + $administrator = presetGrants($seeder)[1] ?? []; + + expect($administrator)->toContain('settings') + ->and($administrator)->toContain('bk_manager'); +})->with(PRESET_SEEDERS); + +test('the install stub and the core baseline-repair seeder agree on every role preset', function () { + $stub = presetGrants(PRESET_SEEDERS[0]); + $core = presetGrants(PRESET_SEEDERS[1]); + + expect(array_keys($stub))->toBe(array_keys($core)); + foreach ($stub as $roleId => $permissions) { + expect($core[$roleId])->toBe($permissions, "role {$roleId} preset drifted between the two seeders"); + } +}); diff --git a/install/stubs/seeds/install/UserRolesTableSeeder.php b/install/stubs/seeds/install/UserRolesTableSeeder.php index 845a364180..42b9160c5d 100644 --- a/install/stubs/seeds/install/UserRolesTableSeeder.php +++ b/install/stubs/seeds/install/UserRolesTableSeeder.php @@ -27,7 +27,7 @@ public function run(): void 2 => [ 'name' => 'Publisher', - 'description' => 'Editor with expanded permissions including manage users, update Elements and site settings', + 'description' => 'Editor with expanded permissions including manage users and update Elements', ] ]); $insertArray = [ @@ -171,8 +171,6 @@ public function run(): void ['permission' => 'save_user', 'role_id' => 3], ['permission' => 'delete_user', 'role_id' => 3], ['permission' => 'logs', 'role_id' => 3], - ['permission' => 'settings', 'role_id' => 3], - ['permission' => 'bk_manager', 'role_id' => 3], ['permission' => 'remove_locks', 'role_id' => 3], ['permission' => 'display_locks', 'role_id' => 3], ['permission' => 'access_permissions', 'role_id' => 3], From f3475da7979bd2d5f877fc6e1cc1226a132cb474 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 19:52:03 +0100 Subject: [PATCH 35/50] fix(acl): require publish_document to run the due (un)publish on Refresh site Refresh site (a=26) both clears the cache and runs the due (un)publish - publishing every document past its pub_date and unpublishing every one past its unpub_date. The page is a cache tool (gated on empty_cache), so a manager with empty_cache but without publish_document could change document publication state through it. Gate only the state-changing step: the due (un)publish now runs only for a user who holds publish_document, while the cache clear still runs for any empty_cache holder. Documents past their schedule are (un)published on the front end regardless (Core::runPubStatusSync()), so nothing that should go live is held back. Co-Authored-By: Claude Opus 4.8 --- core/src/Controllers/RefreshSite.php | 30 ++++++++++++------- .../Security/ManagerAuthorizationGapsTest.php | 22 ++++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/core/src/Controllers/RefreshSite.php b/core/src/Controllers/RefreshSite.php index c027d3610d..3c5457b7e3 100644 --- a/core/src/Controllers/RefreshSite.php +++ b/core/src/Controllers/RefreshSite.php @@ -41,22 +41,32 @@ public function canView(): bool */ public function process(): bool { - // (un)publishing of documents, version 2! - // first, publish document waiting to be published - $time = $this->managerTheme->getCore()->timestamp(); - - $this->parameters = [ - 'num_rows_pub' => $this->publishDocuments($time), - 'num_rows_unpub' => $this->unPublishDocuments($time), - ]; + $core = $this->managerTheme->getCore(); + + // Running the due (un)publish changes document state, so it needs + // publish_document; empty_cache alone only clears the cache. Documents + // past their schedule are (un)published on the front end regardless + // (Core::runPubStatusSync()), so the cache clear below still runs. + $time = $core->timestamp(); + if ($core->hasPermission('publish_document') === 1) { + $this->parameters = [ + 'num_rows_pub' => $this->publishDocuments($time), + 'num_rows_unpub' => $this->unPublishDocuments($time), + ]; + } else { + $this->parameters = [ + 'num_rows_pub' => 0, + 'num_rows_unpub' => 0, + ]; + } ob_start(); - $this->managerTheme->getCore()->clearCache('full', true); + $core->clearCache('full', true); $this->parameters['cache_log'] = ob_get_contents(); ob_end_clean(); // invoke OnSiteRefresh event - $this->managerTheme->getCore()->invokeEvent("OnSiteRefresh"); + $core->invokeEvent("OnSiteRefresh"); return true; } diff --git a/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php b/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php index f1024f00bc..8bc8ef792f 100644 --- a/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php +++ b/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php @@ -42,3 +42,25 @@ function controllerSource(string $class): string ->toContain("hasPermission('settings')") ->and($source)->not->toContain("hasPermission('logs')"); })->with(['Phpinfo', 'SystemInfo']); + +it('runs the due (un)publish on Refresh site only with publish_document, but still clears the cache', function () { + $source = controllerSource('RefreshSite'); + + $process = strpos($source, 'public function process()'); + $gate = strpos($source, "hasPermission('publish_document')", $process); + $publish = strpos($source, '$this->publishDocuments($time)', $process); + $skip = strpos($source, "'num_rows_pub' => 0", $process); + $clear = strpos($source, "clearCache('full'", $process); + + expect($process)->not->toBeFalse() + ->and($gate)->not->toBeFalse() + // the state-changing (un)publish sits behind the permission gate + ->and($publish)->not->toBeFalse() + ->and($gate)->toBeLessThan($publish) + // an explicit no-op branch for a user without the right + ->and($skip)->not->toBeFalse() + // the cache clear runs after the branch, for empty_cache holders either way + ->and($clear)->not->toBeFalse() + ->and($publish)->toBeLessThan($clear) + ->and($skip)->toBeLessThan($clear); +}); From 2453e7ae1a7d2657111887c5a645c7fecaf9634e Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 20:37:20 +0100 Subject: [PATCH 36/50] fix(cache): keep an overdue publish schedule in the refresh threshold Addresses a scheduling regression in the Refresh-site permission gate: when the due (un)publish is skipped (no publish_document) the cache is still cleared, which rebuilds the publish threshold through getCacheRefreshTime(). That method only looked at future pub_date / unpub_date, so a transition already past its time but not yet synced dropped out of the threshold, which became the next future date or 0. Core::updatePubStatus() then never scheduled the sync and the stored published flag could stay stale indefinitely - the front-end fallback was not actually guaranteed. getCacheRefreshTime() now also detects an overdue-but-unsynced transition, with the exact predicate Core::runPubStatusSync() applies, and pins the threshold to now so the next front-end request runs the sync. Once the sync has applied the work the predicate no longer matches, so the threshold advances to the next future date (or 0) - no sync-on-every-request loop. Adds behavioral regression tests that drive the real threshold calculation and the real Core::runPubStatusSync() end to end, for both publish and unpublish, with and without a later future schedule. Co-Authored-By: Claude Opus 4.8 --- core/src/Legacy/Cache.php | 23 ++ .../Legacy/PublishScheduleOverdueSyncTest.php | 198 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php diff --git a/core/src/Legacy/Cache.php b/core/src/Legacy/Cache.php index be876f3e61..c3933ee14c 100644 --- a/core/src/Legacy/Cache.php +++ b/core/src/Legacy/Cache.php @@ -281,6 +281,29 @@ public function getCacheRefreshTime() $timesArr[] = $this->cacheRefreshTime; } + // A scheduled transition whose time has already passed but whose published flag is + // still unsynced (e.g. the cache was rebuilt without applying it) must keep triggering + // a front-end sync. The two queries above only look ahead, so without this an overdue + // transition would drop out of the threshold and Core::updatePubStatus() would never + // run the sync. Pin the threshold to now instead; the predicate mirrors + // Core::runPubStatusSync() exactly, so it pins only while that sync still has work to do. + $overduePending = Models\SiteContent::query() + ->where(function ($query) { + $query->where('published', 0) + ->where('pub_date', '!=', 0) + ->where('pub_date', '<=', $this->request_time); + }) + ->orWhere(function ($query) { + $query->where('published', 1) + ->where('unpub_date', '!=', 0) + ->where('unpub_date', '<=', $this->request_time); + }) + ->exists(); + + if ($overduePending) { + $timesArr[] = $this->request_time; + } + if (count($timesArr) > 0) { $cacheRefreshTime = min($timesArr); } else { diff --git a/core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php b/core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php new file mode 100644 index 0000000000..8356203c0a --- /dev/null +++ b/core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php @@ -0,0 +1,198 @@ + Legacy\Cache::emptyCache() -> publishTimeConfig()). +| +| publishTimeConfig() recomputes the publish threshold from getCacheRefreshTime(). +| That method used to look only at FUTURE pub_date / unpub_date, so a transition +| already past its time but not yet synced dropped out of the threshold, which +| became the next future date or 0. Core::updatePubStatus() then never scheduled the +| sync, and the stored published flag stayed stale indefinitely - the front-end +| fallback relied on in the PR did not actually fire. +| +| getCacheRefreshTime() now also detects an overdue-but-unsynced transition - with +| the exact predicate Core::runPubStatusSync() applies - and pins the threshold to +| "now" so the next front-end request runs the sync. These tests drive the real +| threshold calculation and the real Core::runPubStatusSync() end to end. +| +*/ + +/** + * Drives the real Core::runPubStatusSync() against the harness database without the heavy + * Core bootstrap: only the handful of methods that method calls are overridden. + */ +class OverduePubSyncCore extends Core +{ + /** @var array */ + public array $firedEvents = []; + /** @var string[] */ + public array $clearedCaches = []; + public string $cachePath = ''; + + public function __construct() + { + // bypass the full Core bootstrap; runPubStatusSync() needs none of it + } + + public function invokeEvent($evtName, $extParams = []) + { + $this->firedEvents[] = [$evtName, isset($extParams['docid']) ? (int) $extParams['docid'] : null]; + return false; + } + + public function clearCache($type = '', $report = false) + { + $this->clearedCaches[] = (string) $type; + } + + public function bootstrapPath($path = '') + { + return $this->cachePath; + } + + public function getConfig($name = '', $default = null) + { + return $default; + } + + public function syncNow(int $time): void + { + $this->runPubStatusSync($time); + } +} + +function overdueMakeDoc(array $attrs): void +{ + $doc = new SiteContent(); + $doc->forceFill($attrs + ['pagetitle' => $attrs['alias']]); + $doc->save(); +} + +function overdueThreshold(string $dir): int +{ + $src = (string) file_get_contents($dir . '/sitePublishing.idx.php'); + return preg_match('/\\$cacheRefreshTime\\s*=\\s*\'?(\\d+)\'?/', $src, $m) ? (int) $m[1] : 0; +} + +beforeEach(function () { + $this->dir = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-overdue-' . uniqid(); + mkdir($this->dir); + $capsule = CacheRefreshHarness::boot($this->dir . '/site.sqlite'); + CacheRefreshHarness::schema($capsule); + // runPubStatusSync() writes publishedon; the harness schema does not ship it + $capsule->getConnection()->getSchemaBuilder()->table('site_content', function ($t) { + $t->unsignedInteger('publishedon')->default(0); + }); + + global $evo; + $evo = new CacheRefreshHarness(); + $evo->dir = $this->dir; + $this->evo = $evo; + $this->now = time(); + $_SERVER['REQUEST_TIME'] = $this->now; + + $this->sync = new SyncCache(); + $this->sync->setCachepath($this->dir); +}); + +afterEach(function () { + global $evo; + $evo = null; + Capsule::connection()->disconnect(); + foreach (array_diff(scandir($this->dir), ['.', '..']) as $f) { + @unlink($this->dir . '/' . $f); + } + @rmdir($this->dir); +}); + +it('keeps an overdue transition in the refresh threshold so the front-end sync still applies it', function (string $kind, bool $withFuture) { + $now = $this->now; + + // the overdue, not-yet-synced transition + if ($kind === 'publish') { + overdueMakeDoc(['id' => 1, 'alias' => 'overdue', 'published' => 0, 'pub_date' => $now - 100]); + } else { + overdueMakeDoc(['id' => 1, 'alias' => 'overdue', 'published' => 1, 'unpub_date' => $now - 100]); + } + // optionally a later schedule that is still in the future + if ($withFuture) { + overdueMakeDoc(['id' => 2, 'alias' => 'later', 'published' => 0, 'pub_date' => $now + 3600]); + } + + $before = (int) SiteContent::find(1)->published; + + // Refresh site without publish_document: the (un)publish is skipped, but the cache + // clear rebuilds the publishing threshold (clearCache('full') path). + $this->sync->publishTimeConfig(); + + // the manager action itself did not apply the transition + expect((int) SiteContent::find(1)->published)->toBe($before) + // the cache clear wrote the publishing file + ->and(is_file($this->dir . '/sitePublishing.idx.php'))->toBeTrue(); + + // the overdue transition kept the threshold at/<= now, so updatePubStatus()'s gate + // (timeNow >= cacheRefreshTime && cacheRefreshTime != 0) now passes + $threshold = overdueThreshold($this->dir); + expect($threshold)->not->toBe(0) + ->and($threshold)->toBeLessThanOrEqual($now) + ->and($now >= $threshold && $threshold !== 0)->toBeTrue(); + + // the front-end request then applies the transition and fires the matching event + $core = new OverduePubSyncCore(); + $core->cachePath = $this->dir; + $core->syncNow($now); + + $expectedFlag = $kind === 'publish' ? 1 : 0; + $expectedEvent = $kind === 'publish' ? 'OnDocPublished' : 'OnDocUnPublished'; + expect((int) SiteContent::find(1)->published)->toBe($expectedFlag) + ->and($core->firedEvents)->toContain([$expectedEvent, 1]); + if ($kind === 'unpublish') { + // an unpublish drops only the affected page cache + expect($core->clearedCaches)->toContain('1'); + } + if ($withFuture) { + // the still-future document is left for its own time + expect((int) SiteContent::find(2)->published)->toBe(0); + } + + // the threshold self-heals: the next future date when one remains, else 0 - no + // sync-on-every-request loop once the overdue work is done + expect(overdueThreshold($this->dir))->toBe($withFuture ? $now + 3600 : 0); +})->with([ + 'overdue publish, no future schedule' => ['publish', false], + 'overdue publish, later future schedule' => ['publish', true], + 'overdue unpublish, no future schedule' => ['unpublish', false], + 'overdue unpublish, later future schedule' => ['unpublish', true], +]); + +it('does not pin the threshold to now when nothing is actually overdue', function () { + $now = $this->now; + // a future schedule, plus a past date that has already been applied (published = 1) + overdueMakeDoc(['id' => 1, 'alias' => 'later', 'published' => 0, 'pub_date' => $now + 3600]); + overdueMakeDoc(['id' => 2, 'alias' => 'applied', 'published' => 1, 'pub_date' => $now - 100]); + + $this->sync->publishTimeConfig(); + + // threshold is the future date, not pinned to now - an already-applied past date + // (published = 1) is not treated as overdue, so there is no sync-every-request loop + expect(overdueThreshold($this->dir))->toBe($now + 3600); +}); + +it('writes a zero threshold when there is no schedule at all', function () { + $this->sync->publishTimeConfig(); + + expect(overdueThreshold($this->dir))->toBe(0); +}); From fa6b77402657715c7e9ff27a4be366417488e474 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 21:08:40 +0100 Subject: [PATCH 37/50] add(acl): DB-backed Publisher preset seeder tests, narrow preservation wording Addresses review feedback on the preset change: - Clarifies that the core baseline-repair seeder preserves existing permission *grants* (it only inserts, never revokes), while it does still upsert the role name/description - so the Publisher description text is updated on an existing site. The earlier "existing installs are unaffected" wording is narrowed to "existing grants are preserved". - Adds Feature/PublisherDefaultPresetDatabaseTest, which runs the real Database\Seeders\UserRolesTableSeeder against SQLite and verifies the behaviour directly: a fresh install grants Publisher neither settings nor bk_manager (administrator keeps both); a repeated run is idempotent (no duplicate rows, preset unchanged); and an existing site keeps its settings / bk_manager / custom grants while the description is upserted. Co-Authored-By: Claude Opus 4.8 --- .../PublisherDefaultPresetDatabaseTest.php | 130 ++++++++++++++++++ .../Install/PublisherDefaultPresetTest.php | 9 +- 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 core/tests/Feature/PublisherDefaultPresetDatabaseTest.php diff --git a/core/tests/Feature/PublisherDefaultPresetDatabaseTest.php b/core/tests/Feature/PublisherDefaultPresetDatabaseTest.php new file mode 100644 index 0000000000..d36ec66cae --- /dev/null +++ b/core/tests/Feature/PublisherDefaultPresetDatabaseTest.php @@ -0,0 +1,130 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $container = $capsule->getContainer(); + $container->instance('db', $capsule->getDatabaseManager()); + $container->bind('db.schema', fn () => $capsule->getConnection()->getSchemaBuilder()); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($container); + Model::setConnectionResolver($capsule->getDatabaseManager()); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + $schema->create('user_roles', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + }); + $schema->create('role_permissions', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('role_id')->default(0); + $table->string('permission')->default(''); + $table->timestamps(); + }); + + return $capsule; +} + +/** @return string[] the permission keys granted to a role */ +function presetGrantsFor(Capsule $capsule, int $roleId): array +{ + return $capsule->getConnection()->table('role_permissions') + ->where('role_id', $roleId)->pluck('permission')->all(); +} + +it('grants a fresh install Publisher neither settings nor bk_manager, and the administrator both', function () { + $capsule = bootPublisherPresetDatabase(); + + (new UserRolesTableSeeder())->run(); + + $publisher = presetGrantsFor($capsule, 3); + $administrator = presetGrantsFor($capsule, 1); + + expect($publisher)->not->toContain('settings') + ->and($publisher)->not->toContain('bk_manager') + // still a working content role + ->and($publisher)->toContain('save_document') + ->and($publisher)->toContain('delete_chunk') + // the administrator keeps both + ->and($administrator)->toContain('settings') + ->and($administrator)->toContain('bk_manager'); +}); + +it('is idempotent: a repeated run adds no duplicate rows and keeps the preset', function () { + $capsule = bootPublisherPresetDatabase(); + $db = $capsule->getConnection(); + + (new UserRolesTableSeeder())->run(); + $afterFirst = $db->table('role_permissions')->count(); + + (new UserRolesTableSeeder())->run(); + + expect($db->table('role_permissions')->count())->toBe($afterFirst) + // no (role_id, permission) pair is duplicated + ->and( + $db->table('role_permissions') + ->selectRaw('role_id, permission, COUNT(*) as c') + ->groupBy('role_id', 'permission') + ->havingRaw('COUNT(*) > 1')->count() + )->toBe(0) + ->and(presetGrantsFor($capsule, 3))->not->toContain('settings') + ->and(presetGrantsFor($capsule, 3))->not->toContain('bk_manager'); +}); + +it('preserves settings, bk_manager and custom grants on an existing site, while upserting the description', function () { + $capsule = bootPublisherPresetDatabase(); + $db = $capsule->getConnection(); + + // an existing site: Publisher already holds settings + bk_manager and a custom grant, + // under an operator-renamed role + $db->table('user_roles')->insert(['id' => 3, 'name' => 'Senior Publisher', 'description' => 'our own wording']); + $db->table('role_permissions')->insert([ + ['role_id' => 3, 'permission' => 'settings', 'created_at' => null, 'updated_at' => null], + ['role_id' => 3, 'permission' => 'bk_manager', 'created_at' => null, 'updated_at' => null], + ['role_id' => 3, 'permission' => 'my_custom_perm', 'created_at' => null, 'updated_at' => null], + ]); + + (new UserRolesTableSeeder())->run(); + + $publisher = presetGrantsFor($capsule, 3); + // grants are preserved - the seeder never revokes + expect($publisher)->toContain('settings') + ->and($publisher)->toContain('bk_manager') + ->and($publisher)->toContain('my_custom_perm'); + + // the description (and name) are upserted, so they do change on an existing site + $role = $db->table('user_roles')->where('id', 3)->first(); + expect($role->name)->toBe('Publisher') + ->and($role->description)->toBe('Editor with expanded permissions including manage users and update Elements'); +}); diff --git a/core/tests/Unit/Install/PublisherDefaultPresetTest.php b/core/tests/Unit/Install/PublisherDefaultPresetTest.php index ab556d5fc7..f5b3e8639f 100644 --- a/core/tests/Unit/Install/PublisherDefaultPresetTest.php +++ b/core/tests/Unit/Install/PublisherDefaultPresetTest.php @@ -16,9 +16,12 @@ | upload allow-lists) and the Mail tab (SMTP). | | Neither belongs in a content role by default, so the default preset no -| longer grants them. This only changes fresh installs: the core -| baseline-repair seeder only inserts missing rows, so an existing site that -| already granted them keeps them. +| longer grants them. On an existing site the core baseline-repair seeder only +| ever inserts missing rows, so existing permission grants are preserved: it +| never revokes settings or bk_manager from a role that already holds them. +| (That seeder does still upsert the role name and description, so the Publisher +| description text is updated on an existing site; only the grants are preserved. +| The database behaviour is covered in Feature/PublisherDefaultPresetDatabaseTest.) | | Two seeders define the preset and must agree: the install stub | (install/stubs/seeds/install) seeds a fresh install, and the idempotent From 34263ae4f9b79ca29f4be2cce5f855a2957c2b17 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 8 Nov 2026 21:15:18 +0100 Subject: [PATCH 38/50] fix(acl): refuse deleting a role that is still assigned to users The role editor's delete action (a=35/36/38, UserRoles\UserRole) removed a role and its permission rows outright without checking whether any user still held it. The delete does not reassign those users, so they were left pointing at a role id that no longer exists - a manager account with no resolvable permissions. The delete now refuses while any user is still assigned to the role, with a lexicon message asking to reassign those users first. The legacy delete_role.processor.php already refused; the controller path the role UI actually uses did not. Adds a lexicon key role_in_use_message (English plus all other locales) and tests: a behavioral check that an in-use role is kept (role and its permissions survive) while an unused role still deletes, and source / lexicon wiring checks. Co-Authored-By: Claude Opus 4.8 --- core/lang/az/global.php | 1 + core/lang/be/global.php | 1 + core/lang/bg/global.php | 1 + core/lang/cs/global.php | 1 + core/lang/da/global.php | 1 + core/lang/de/global.php | 1 + core/lang/en/global.php | 1 + core/lang/es/global.php | 1 + core/lang/et/global.php | 1 + core/lang/fa/global.php | 1 + core/lang/fi/global.php | 1 + core/lang/fr/global.php | 1 + core/lang/he/global.php | 1 + core/lang/hy/global.php | 1 + core/lang/it/global.php | 1 + core/lang/ja/global.php | 1 + core/lang/ka/global.php | 1 + core/lang/kk/global.php | 1 + core/lang/ky/global.php | 1 + core/lang/lt/global.php | 1 + core/lang/lv/global.php | 1 + core/lang/nl/global.php | 1 + core/lang/nn/global.php | 1 + core/lang/pl/global.php | 1 + core/lang/pt/global.php | 1 + core/lang/ro/global.php | 1 + core/lang/sk/global.php | 1 + core/lang/sv/global.php | 1 + core/lang/tg/global.php | 1 + core/lang/tk/global.php | 1 + core/lang/uk/global.php | 1 + core/lang/uz/global.php | 1 + core/lang/zh/global.php | 1 + core/src/Controllers/UserRoles/UserRole.php | 5 + .../Unit/Security/RoleInUseDeleteTest.php | 142 ++++++++++++++++++ 35 files changed, 180 insertions(+) create mode 100644 core/tests/Unit/Security/RoleInUseDeleteTest.php diff --git a/core/lang/az/global.php b/core/lang/az/global.php index 26351ef9e8..f9f02913e7 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -357,6 +357,7 @@ $_lang["role_help"] = 'Kömək səhifələrinə baxış'; $_lang["role_home"] = 'Menecerin giriş səhifəsinə keçid'; $_lang["role_import_static"] = 'HTML idxalı'; +$_lang["role_in_use_message"] = 'Bu rol hələ də bir və ya bir neçə istifadəçiyə təyin edilib. Silməzdən əvvəl həmin istifadəçiləri başqa rola təyin edin.'; $_lang["role_widget_recent_info"] = 'Son redaktə olunan/yaradılan resurslar vidcetinə bax'; $_lang["role_list_module"] = 'Modulların siyahısı'; $_lang["role_manage_metatags"] = 'Saytın META teqlərini və açar sözlərini idarə et'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index e0f041c3c9..03d3dd6341 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -345,6 +345,7 @@ $_lang["role_help"] = 'Прагляд старонкі даведкі'; $_lang["role_home"] = 'Уводная старонка менеджэра запытаў'; $_lang["role_import_static"] = 'Імпарт HTML'; +$_lang["role_in_use_message"] = 'Гэтая роля ўсё яшчэ прызначана аднаму ці некалькім карыстальнікам. Перш чым яе выдаліць, прызначце гэтым карыстальнікам іншую ролю.'; $_lang["role_widget_recent_info"] = 'Прагляд віджэта «Нядаўна рэдагаваныя/створаныя рэсурсы»'; $_lang["role_list_module"] = 'Модуль спісу'; $_lang["role_manage_metatags"] = 'Кіруйце мета-тэгамі і ключавымі словамі сайта'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 3d3cb3875c..f1a0538127 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -806,6 +806,7 @@ $_lang["role_help"] = 'Преглед на страниците Помощ'; $_lang["role_home"] = 'Викане на началната страница на мениджъра'; $_lang["role_import_static"] = 'Импорт на HTML'; +$_lang["role_in_use_message"] = 'Тази роля все още е присвоена на един или повече потребители. Преди да я изтриете, преназначете тези потребители към друга роля.'; $_lang["role_widget_recent_info"] = 'Преглед на уиджета „Наскоро редактирани/създадени документи“'; $_lang["role_manage_metatags"] = 'Управление на META тагове и ключови думи на сайта'; $_lang["role_management_msg"] = 'Тук можете да изберете коя роля да редактирате.'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 251bb590b6..365c608e6b 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -810,6 +810,7 @@ $_lang["role_help"] = 'Zobrazit pomocníka'; $_lang["role_home"] = 'Použít správce úvodní stránky'; $_lang["role_import_static"] = 'Importovat HTML'; +$_lang["role_in_use_message"] = 'Tato role je stále přiřazena jednomu nebo více uživatelům. Než ji bude možné smazat, přiřaďte tyto uživatele k jiné roli.'; $_lang["role_widget_recent_info"] = 'Zobrazit widget Naposledy vytvořené nebo upravené dokumenty'; $_lang["role_manage_metatags"] = 'Upravit META tagy a klíčové slova celého webu'; $_lang["role_management_msg"] = 'Zde můžete zvolit oprávnění, které chcete upravit.'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index ab3f308077..c78568d013 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -807,6 +807,7 @@ $_lang["role_help"] = 'Se hjælpe siderne'; $_lang["role_home"] = 'Må se CMS\'ets introduktionsside'; $_lang["role_import_static"] = 'Importere HTML'; +$_lang["role_in_use_message"] = 'Denne rolle er stadig tildelt en eller flere brugere. Tildel disse brugere en anden rolle, før den kan slettes.'; $_lang["role_widget_recent_info"] = 'Vis widgetten Seneste redigerede eller oprettede ressourcer'; $_lang["role_manage_metatags"] = 'Administrere websitets META tags og keywords'; $_lang["role_management_msg"] = 'Vælg rollen der skal redigeres.'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index 196daa3880..a9392b9d76 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -856,6 +856,7 @@ $_lang["role_help"] = 'Anzeigen der Hilfeseiten'; $_lang["role_home"] = 'Manager Startseite ermitteln'; $_lang["role_import_static"] = 'HTML importieren'; +$_lang["role_in_use_message"] = 'Diese Rolle ist noch einem oder mehreren Benutzern zugewiesen. Weisen Sie diese Benutzer einer anderen Rolle zu, bevor sie gelöscht werden kann.'; $_lang["role_widget_recent_info"] = 'Widget „Kürzlich bearbeitete oder erstellte Ressourcen“ anzeigen'; $_lang["role_manage_metatags"] = 'Meta-Tags und Schlüsselwörter verwalten'; $_lang["role_management_msg"] = 'Hier können Sie eine Rolle zum Bearbeiten wählen.'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 96c6eee69b..afbfa4dae9 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'View help pages'; $_lang["role_home"] = 'Request manager intro page'; $_lang["role_import_static"] = 'Import HTML'; +$_lang["role_in_use_message"] = 'This role is still assigned to one or more users. Reassign those users to another role before it can be deleted.'; $_lang["role_widget_recent_info"] = 'View widget Recently edited/created Resources'; $_lang["role_list_module"] = 'List Module'; $_lang["role_manage_metatags"] = 'Manage site META tags and keywords'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index aff1897d3b..4b8aa6e038 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -858,6 +858,7 @@ $_lang["role_help"] = 'Ver las páginas de ayuda'; $_lang["role_home"] = 'Solicitar página de inicio del administrador'; $_lang["role_import_static"] = 'Importar HTML'; +$_lang["role_in_use_message"] = 'Este rol todavía está asignado a uno o más usuarios. Reasigne esos usuarios a otro rol antes de poder eliminarlo.'; $_lang["role_widget_recent_info"] = 'Ver widget Documentos recientemente editados/creados'; $_lang["role_manage_metatags"] = 'Administrar las etiquetas META y las Palabras clave del sitio'; $_lang["role_management_msg"] = 'Elige el rol que deseas editar.'; diff --git a/core/lang/et/global.php b/core/lang/et/global.php index 519b563b4d..83680af98a 100644 --- a/core/lang/et/global.php +++ b/core/lang/et/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Vaata abilehti'; $_lang["role_home"] = 'Laadi haldusliidese avaleht'; $_lang["role_import_static"] = 'Impordi HTML-i'; +$_lang["role_in_use_message"] = 'See roll on endiselt määratud ühele või mitmele kasutajale. Enne kustutamist määrake need kasutajad teisele rollile.'; $_lang["role_widget_recent_info"] = 'Vaata vidinat „Hiljuti muudetud või loodud ressursid“'; $_lang["role_list_module"] = 'Kuva moodulite loend'; $_lang["role_manage_metatags"] = 'Halda saidi META-silte ja märksõnu'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 4cc02c75c6..0fd11f0584 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -807,6 +807,7 @@ $_lang["role_help"] = 'مشاهده ی صفحات راهنما'; $_lang["role_home"] = 'درخواست صفحه ی مقدمه ی مدیریت'; $_lang["role_import_static"] = 'واردسازی HTML'; +$_lang["role_in_use_message"] = 'این نقش هنوز به یک یا چند کاربر اختصاص داده شده است. پیش از حذف، این کاربران را به نقش دیگری منتقل کنید.'; $_lang["role_widget_recent_info"] = 'مشاهده ویجت پرونده هایی که به تازگی ایجاد / ویرایش شده اند'; $_lang["role_manage_metatags"] = 'مدیریت کلمات شاخص و کلیدی وبگاه'; $_lang["role_management_msg"] = 'در اینجا شما میتوانید نقش مورد نظر خود را برای ویرایش انتخاب کنید.'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index a8c64f9a8c..76beacc5a1 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -806,6 +806,7 @@ $_lang["role_help"] = 'Näytä ohjesivut'; $_lang["role_home"] = 'Näytä aloitussivu'; $_lang["role_import_static"] = 'Tuo HTML'; +$_lang["role_in_use_message"] = 'Tämä rooli on edelleen määritetty yhdelle tai useammalle käyttäjälle. Määritä nämä käyttäjät toiseen rooliin, ennen kuin se voidaan poistaa.'; $_lang["role_widget_recent_info"] = 'Näytä widget Viimeisimmät sivut'; $_lang["role_manage_metatags"] = 'Hallitse sivuston metatietoja ja avainsanoja'; $_lang["role_management_msg"] = 'Muokkaa rooleja.'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index ed9e413690..3d6957b990 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -809,6 +809,7 @@ $_lang["role_help"] = 'Accès aux pages d\'aide'; $_lang["role_home"] = 'Accès à la page d\'introduction du gestionnaire'; $_lang["role_import_static"] = 'Importation HTML'; +$_lang["role_in_use_message"] = 'Ce rôle est encore attribué à un ou plusieurs utilisateurs. Réaffectez ces utilisateurs à un autre rôle avant de pouvoir le supprimer.'; $_lang["role_widget_recent_info"] = 'Voir le widget Ressources éditées/créées récemment'; $_lang["role_manage_metatags"] = 'Gérer les balises META et les mots-clés du site'; $_lang["role_management_msg"] = 'Choisissez le rôle que vous souhaitez modifier.'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index c65b26abdb..ee7715fac9 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -807,6 +807,7 @@ $_lang["role_help"] = 'הצג דפי עזרה'; $_lang["role_home"] = 'Request manager intro page'; $_lang["role_import_static"] = 'Import HTML'; +$_lang["role_in_use_message"] = 'תפקיד זה עדיין משויך למשתמש אחד או יותר. שייך משתמשים אלה לתפקיד אחר לפני שניתן יהיה למחוק אותו.'; $_lang["role_widget_recent_info"] = 'הצג את הווידג׳ט מסמכים שנערכו/נוצרו לאחרונה'; $_lang["role_manage_metatags"] = 'Manage site META tags and keywords'; $_lang["role_management_msg"] = 'Choose the role you wish to edit.'; diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php index 5a678f4cce..a1b58820d9 100644 --- a/core/lang/hy/global.php +++ b/core/lang/hy/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Դիտել օգնության էջերը'; $_lang["role_home"] = 'Բացել կառավարման վահանակի ներածական էջը'; $_lang["role_import_static"] = 'Ներմուծել HTML'; +$_lang["role_in_use_message"] = 'Այս դերը դեռ վերագրված է մեկ կամ մի քանի օգտատերի: Ջնջելուց առաջ վերագրեք այդ օգտատերերին մեկ այլ դերի:'; $_lang["role_widget_recent_info"] = 'Դիտել վերջերս խմբագրված/ստեղծված ռեսուրսների վիջեթը'; $_lang["role_list_module"] = 'Ցուցադրել մոդուլների ցանկը'; $_lang["role_manage_metatags"] = 'Կառավարել կայքի META թեգերն ու հիմնաբառերը'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index 62d31ff99c..ee530b8b4a 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -858,6 +858,7 @@ $_lang["role_help"] = 'Visualizza le pagine di aiuto'; $_lang["role_home"] = 'Visualizza la pagina introduttiva del Manager EVO'; $_lang["role_import_static"] = 'Importa HTML'; +$_lang["role_in_use_message"] = 'Questo ruolo è ancora assegnato a uno o più utenti. Riassegna tali utenti a un altro ruolo prima di poterlo eliminare.'; $_lang["role_widget_recent_info"] = 'Visualizza il widget Risorse modificate o create di recente'; $_lang["role_manage_metatags"] = 'Gestione META tag e parole chiave'; $_lang["role_management_msg"] = 'Qui potete scegliere il Ruolo da modificare o creare un nuovo Ruolo.'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 9f4787ee04..0cb97b8f5a 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -887,6 +887,7 @@ $_lang["role_help"] = 'ヘルプページを表示'; $_lang["role_home"] = '管理画面ホームへのアクセス'; $_lang["role_import_static"] = '静的HTMLファイルをインポート'; +$_lang["role_in_use_message"] = 'このロールはまだ 1 人以上のユーザーに割り当てられています。削除する前に、これらのユーザーを別のロールに割り当て直してください。'; $_lang["role_widget_recent_info"] = 'ウィジェット「最近編集したリソース」を表示'; $_lang["role_list_module"] = 'モジュール一覧'; $_lang["role_manage_metatags"] = 'サイト全体METAタグとキーワードの管理'; diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php index f59adc9cce..ccd1216b58 100644 --- a/core/lang/ka/global.php +++ b/core/lang/ka/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'დახმარების გვერდების ნახვა'; $_lang["role_home"] = 'მართვის პანელის საწყისი გვერდის მოთხოვნა'; $_lang["role_import_static"] = 'HTML-ის იმპორტი'; +$_lang["role_in_use_message"] = 'ეს როლი ჯერ კიდევ მინიჭებულია ერთ ან მეტ მომხმარებელს. წაშლამდე მიანიჭეთ ამ მომხმარებლებს სხვა როლი.'; $_lang["role_widget_recent_info"] = 'ბოლოს შეცვლილი/შექმნილი რესურსების ვიჯეტის ნახვა'; $_lang["role_list_module"] = 'მოდულების ჩამონათვალი'; $_lang["role_manage_metatags"] = 'საიტის META ტეგებისა და საკვანძო სიტყვების მართვა'; diff --git a/core/lang/kk/global.php b/core/lang/kk/global.php index fe39072d02..3b0ddb234d 100644 --- a/core/lang/kk/global.php +++ b/core/lang/kk/global.php @@ -903,6 +903,7 @@ $_lang["role_help"] = 'Көмек беттерін қарау'; $_lang["role_home"] = 'Басқару тақтасының бастапқы бетін сұрау'; $_lang["role_import_static"] = 'HTML импорттау'; +$_lang["role_in_use_message"] = 'Бұл рөл әлі де бір немесе бірнеше пайдаланушыға тағайындалған. Жоймас бұрын бұл пайдаланушыларды басқа рөлге тағайындаңыз.'; $_lang["role_widget_recent_info"] = 'Жақында өңделген/жасалған ресурстар виджетін қарау'; $_lang["role_list_module"] = 'Модуль тізімі'; $_lang["role_manage_metatags"] = 'Сайттың META тегтері мен кілт сөздерін басқару'; diff --git a/core/lang/ky/global.php b/core/lang/ky/global.php index d93a1aeaaa..03caad62d2 100644 --- a/core/lang/ky/global.php +++ b/core/lang/ky/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Жардам барактарын көрүү'; $_lang["role_home"] = 'Башкаруу панелинин башкы барагын суроо'; $_lang["role_import_static"] = 'HTML импорттоо'; +$_lang["role_in_use_message"] = 'Бул роль дагы эле бир же бир нече колдонуучуга дайындалган. Аны өчүрүүдөн мурда ал колдонуучуларды башка ролго дайындаңыз.'; $_lang["role_widget_recent_info"] = 'Жакында түзөтүлгөн/түзүлгөн ресурстар виджетин көрүү'; $_lang["role_list_module"] = 'Модулдардын тизмесин көрүү'; $_lang["role_manage_metatags"] = 'Сайттын META тегдерин жана ачкыч сөздөрүн башкаруу'; diff --git a/core/lang/lt/global.php b/core/lang/lt/global.php index 3e899fe468..9260a127fd 100644 --- a/core/lang/lt/global.php +++ b/core/lang/lt/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Peržiūrėti žinyno puslapius'; $_lang["role_home"] = 'Užklausti administravimo pradžios puslapį'; $_lang["role_import_static"] = 'Importuoti HTML'; +$_lang["role_in_use_message"] = 'Šis vaidmuo vis dar priskirtas vienam ar keliems naudotojams. Prieš ištrindami priskirkite tuos naudotojus kitam vaidmeniui.'; $_lang["role_widget_recent_info"] = 'Peržiūrėti neseniai redaguotų / sukurtų išteklių valdiklį'; $_lang["role_list_module"] = 'Rodyti modulį sąraše'; $_lang["role_manage_metatags"] = 'Tvarkyti svetainės META žymas ir reikšminius žodžius'; diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php index edeec426bd..7f91340c7f 100644 --- a/core/lang/lv/global.php +++ b/core/lang/lv/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Skatīt palīdzības lapas'; $_lang["role_home"] = 'Pieprasīt pārvaldības paneļa sākumlapu'; $_lang["role_import_static"] = 'Importēt HTML'; +$_lang["role_in_use_message"] = 'Šī loma joprojām ir piešķirta vienam vai vairākiem lietotājiem. Pirms tās dzēšanas piešķiriet šos lietotājus citai lomai.'; $_lang["role_widget_recent_info"] = 'Skatīt nesen rediģēto/izveidoto resursu logrīku'; $_lang["role_list_module"] = 'Rādīt moduli sarakstā'; $_lang["role_manage_metatags"] = 'Pārvaldīt vietnes META tagus un atslēgvārdus'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 0c09b6214f..a3a7bb752e 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -846,6 +846,7 @@ $_lang["role_help"] = 'Help Pagina\'s weergeven'; $_lang["role_home"] = 'Verzoek om Content Management Systeem Intropagina'; $_lang["role_import_static"] = 'Importeer HTML'; +$_lang["role_in_use_message"] = 'Deze rol is nog toegewezen aan een of meer gebruikers. Wijs deze gebruikers aan een andere rol toe voordat de rol kan worden verwijderd.'; $_lang["role_widget_recent_info"] = 'Widget Recent gemaakte/bewerkte Pagina\'s weergeven'; $_lang["role_manage_metatags"] = 'Beheer \'META tags\' en keywords'; $_lang["role_management_msg"] = 'Hier kunt u kiezen welke Rol u wenst te wijzigen.'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 29c85219be..ef8a554fc7 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -806,6 +806,7 @@ $_lang["role_help"] = 'Vis hjelpesider'; $_lang["role_home"] = 'Last introduksjonssiden'; $_lang["role_import_static"] = 'Import HTML'; +$_lang["role_in_use_message"] = 'Denne rolla er framleis tildelt éin eller fleire brukarar. Tildel desse brukarane ei anna rolle før ho kan slettast.'; $_lang["role_widget_recent_info"] = 'Vis widgeten Nylig opprettede/redigerte dokumenter'; $_lang["role_manage_metatags"] = 'Håndtere webplassens META-tagger og nøkkelord'; $_lang["role_management_msg"] = 'Her kan du opprette en ny rolle eller velge en allerede eksisterende rolle for redigering.'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index c9a169b2bf..bf8ad53b35 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -859,6 +859,7 @@ $_lang["role_help"] = 'Przeglądanie stron pomocy'; $_lang["role_home"] = 'Pokaż stronę wprowadzającą menedżera'; $_lang["role_import_static"] = 'Import HTML'; +$_lang["role_in_use_message"] = 'Ta rola jest nadal przypisana do co najmniej jednego użytkownika. Przed jej usunięciem przypisz tych użytkowników do innej roli.'; $_lang["role_widget_recent_info"] = 'Wyświetl widżet Ostatnio edytowane dokumenty'; $_lang["role_manage_metatags"] = 'Zarządzaj META tagami i słowami kluczowymi witryny'; $_lang["role_management_msg"] = 'Tutaj możesz wybrać, którą rolę chcesz edytować.'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index d1bbf03eaa..abc5e3f744 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -806,6 +806,7 @@ $_lang["role_help"] = 'Ver páginas de ajuda'; $_lang["role_home"] = 'Pedir página de início do Interface de Gestão'; $_lang["role_import_static"] = 'Importar HTML'; +$_lang["role_in_use_message"] = 'Esta função ainda está atribuída a um ou mais utilizadores. Reatribua esses utilizadores a outra função antes de a poder eliminar.'; $_lang["role_widget_recent_info"] = 'Ver widget Documentos editados/criados recentemente'; $_lang["role_manage_metatags"] = 'Gerir etiquetas (tags) META e palavras-chave do site'; $_lang["role_management_msg"] = 'Aqui pode escolher qual o perfil que deseja alterar.'; diff --git a/core/lang/ro/global.php b/core/lang/ro/global.php index 3035faaf3e..611bae2d1d 100644 --- a/core/lang/ro/global.php +++ b/core/lang/ro/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Vizualizarea paginilor de ajutor'; $_lang["role_home"] = 'Solicitarea paginii de întâmpinare a interfeței de administrare'; $_lang["role_import_static"] = 'Importarea HTML-ului'; +$_lang["role_in_use_message"] = 'Acest rol este încă atribuit unuia sau mai multor utilizatori. Reatribuiți acești utilizatori unui alt rol înainte de a-l putea șterge.'; $_lang["role_widget_recent_info"] = 'Vizualizarea widgetului Resurse editate/create recent'; $_lang["role_list_module"] = 'Listarea modulului'; $_lang["role_manage_metatags"] = 'Gestionarea etichetelor META și a cuvintelor-cheie ale site-ului'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 002a8bbe48..8056725594 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -878,6 +878,7 @@ $_lang["role_help"] = 'Zobrazenie stránok pomoci'; $_lang["role_home"] = 'Požiadavka na úvodnú stránku administrácie'; $_lang["role_import_static"] = 'Import HTML'; +$_lang["role_in_use_message"] = 'Táto rola je stále priradená jednému alebo viacerým používateľom. Pred jej odstránením priraďte týchto používateľov k inej role.'; $_lang["role_widget_recent_info"] = 'Zobrazenie widgetu „Nedávno vytvorené/upravené zdroje“'; $_lang["role_list_module"] = 'Zobrazenie zoznamu modulov'; $_lang["role_manage_metatags"] = 'META tagy a kľúčové slová'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index de6aada43b..94c362c352 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -807,6 +807,7 @@ $_lang["role_help"] = 'Visa hjälpsidor'; $_lang["role_home"] = 'Ladda introduktionssidan'; $_lang["role_import_static"] = 'Importera HTML'; +$_lang["role_in_use_message"] = 'Den här rollen är fortfarande tilldelad en eller flera användare. Tilldela dessa användare en annan roll innan den kan tas bort.'; $_lang["role_widget_recent_info"] = 'Visa widget Nyligen skapade/redigerade resurser'; $_lang["role_manage_metatags"] = 'Hantera webbplatsens META-taggar och nyckelord'; $_lang["role_management_msg"] = 'Skapa en ny roll eller välja en redan befintlig för redigering.'; diff --git a/core/lang/tg/global.php b/core/lang/tg/global.php index 87bfbc7db0..28a67c4e5f 100644 --- a/core/lang/tg/global.php +++ b/core/lang/tg/global.php @@ -877,6 +877,7 @@ $_lang["role_help"] = 'Дидани саҳифаҳои кумак'; $_lang["role_home"] = 'Дархости саҳифаи оғози панел'; $_lang["role_import_static"] = 'Воридоти HTML'; +$_lang["role_in_use_message"] = 'Ин нақш ҳоло ҳам ба як ё якчанд корбар таъин шудааст. Пеш аз нест кардан, ин корбаронро ба нақши дигар таъин кунед.'; $_lang["role_widget_recent_info"] = 'Дидани виҷети захираҳои ба наздикӣ таҳриршуда/сохташуда'; $_lang["role_list_module"] = 'Рӯйхати модулҳо'; $_lang["role_manage_metatags"] = 'Идоракунии META ва калимаҳои калидии сомона'; diff --git a/core/lang/tk/global.php b/core/lang/tk/global.php index 37c8a236a4..4b05ca7c8e 100644 --- a/core/lang/tk/global.php +++ b/core/lang/tk/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Kömek sahypalaryny görmek'; $_lang["role_home"] = 'Dolandyryşyň giriş sahypasyny soramak'; $_lang["role_import_static"] = 'HTML import etmek'; +$_lang["role_in_use_message"] = 'Bu rol entek bir ýa-da birnäçe ulanyja bellenen. Ony pozmazdan ozal şol ulanyjylary başga rola belläň.'; $_lang["role_widget_recent_info"] = 'Ýakynda redaktirlenen/döredilen resurslar widjetini görmek'; $_lang["role_list_module"] = 'Moduly sanawda görkezmek'; $_lang["role_manage_metatags"] = 'Saýtyň META teglerini we açar sözlerini dolandyrmak'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 9418309233..d6a9fa589d 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -885,6 +885,7 @@ $_lang["role_help"] = 'Перегляд сторінок допомоги'; $_lang["role_home"] = 'Запит вхідної сторінки менеджера'; $_lang["role_import_static"] = 'Імпорт HTML'; +$_lang["role_in_use_message"] = 'Ця роль усе ще призначена одному або кільком користувачам. Перш ніж видалити її, призначте цих користувачів до іншої ролі.'; $_lang["role_widget_recent_info"] = 'Перегляд віджета «Нещодавно створені / відредаговані ресурси»'; $_lang["role_list_module"] = 'Перегляд списка модулів'; $_lang["role_manage_metatags"] = 'Управління META-тегами і ключовими словами'; diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php index 7e00d7c075..6b19e8d438 100644 --- a/core/lang/uz/global.php +++ b/core/lang/uz/global.php @@ -882,6 +882,7 @@ $_lang["role_help"] = 'Yordam sahifalarini ko‘rish'; $_lang["role_home"] = 'Boshqaruv panelining kirish sahifasini so‘rash'; $_lang["role_import_static"] = 'HTML ni import qilish'; +$_lang["role_in_use_message"] = 'Bu rol hali ham bir yoki bir nechta foydalanuvchiga biriktirilgan. Uni o‘chirishdan oldin bu foydalanuvchilarni boshqa rolga biriktiring.'; $_lang["role_widget_recent_info"] = 'Yaqinda tahrirlangan/yaratilgan resurslar vidjetini ko‘rish'; $_lang["role_list_module"] = 'Modulni ro‘yxatda ko‘rsatish'; $_lang["role_manage_metatags"] = 'Saytning META teglari va kalit so‘zlarini boshqarish'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 91448fb180..e25d15b52d 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -807,6 +807,7 @@ $_lang["role_help"] = '查看帮助文件'; $_lang["role_home"] = '管理主页面'; $_lang["role_import_static"] = 'Import HTML'; +$_lang["role_in_use_message"] = '此角色仍分配给一个或多个用户。删除前请先将这些用户重新分配到其他角色。'; $_lang["role_widget_recent_info"] = '查看“Recently edited/created Resources”小部件'; $_lang["role_manage_metatags"] = '管理站点的META标签和关键词'; $_lang["role_management_msg"] = '在这里你可以选择一需要编辑的角色。'; diff --git a/core/src/Controllers/UserRoles/UserRole.php b/core/src/Controllers/UserRoles/UserRole.php index b258df1709..225235461f 100644 --- a/core/src/Controllers/UserRoles/UserRole.php +++ b/core/src/Controllers/UserRoles/UserRole.php @@ -37,6 +37,11 @@ public function process(): bool { if(isset($_GET['action']) && $_GET['action'] == 'delete' ){ $id = $this->getElementId(); + // Refuse to delete a role still assigned to users: the delete does not reassign + // them, so they would be left pointing at a role that no longer exists. + if (Models\UserAttribute::where('role', $id)->exists()) { + $this->managerTheme->alertAndQuit('role_in_use_message'); + } Models\RolePermissions::query()->where('role_id', $id)->delete(); Models\SiteModuleRole::query()->where('role', $id)->delete(); Models\UserRoleVar::where('roleid', $id)->delete(); diff --git a/core/tests/Unit/Security/RoleInUseDeleteTest.php b/core/tests/Unit/Security/RoleInUseDeleteTest.php new file mode 100644 index 0000000000..b2fc1c923c --- /dev/null +++ b/core/tests/Unit/Security/RoleInUseDeleteTest.php @@ -0,0 +1,142 @@ +quitMessage = (string) $message; + throw new RuntimeException('alertAndQuit:' . $message); + } +} + +function roleInUseController(RoleInUseThemeStub $theme): UserRoleController +{ + // The delete branch only needs $_REQUEST (getElementId) and $this->managerTheme, + // so the heavy controller constructor can be bypassed. + $controller = (new ReflectionClass(UserRoleController::class))->newInstanceWithoutConstructor(); + $property = new ReflectionProperty(UserRoleController::class, 'managerTheme'); + $property->setAccessible(true); + $property->setValue($controller, $theme); + + return $controller; +} + +beforeEach(function () { + $this->capsule = CacheRefreshHarness::boot(':memory:'); + $schema = $this->capsule->getConnection()->getSchemaBuilder(); + $schema->create('user_roles', function (Blueprint $t) { + $t->increments('id'); + $t->string('name')->default(''); + $t->text('description')->nullable(); + }); + $schema->create('role_permissions', function (Blueprint $t) { + $t->increments('id'); + $t->unsignedInteger('role_id')->default(0); + $t->string('permission')->default(''); + $t->timestamps(); + }); + $schema->create('user_attributes', function (Blueprint $t) { + $t->increments('id'); + $t->unsignedInteger('internalKey')->default(0); + $t->unsignedInteger('role')->default(0); + }); + + $this->capsule->table('user_roles')->insert(['id' => 5, 'name' => 'Content team', 'description' => '']); + $this->capsule->table('role_permissions')->insert(['role_id' => 5, 'permission' => 'edit_document']); +}); + +afterEach(function () { + Capsule::connection()->disconnect(); + foreach (['action', 'id'] as $k) { + unset($_GET[$k], $_REQUEST[$k]); + } +}); + +it('refuses to delete a role while a user still holds it, and keeps the role and its permissions', function () { + // one manager account assigned to role 5 + $this->capsule->table('user_attributes')->insert(['internalKey' => 1, 'role' => 5]); + + $_GET['action'] = $_REQUEST['action'] = 'delete'; + $_GET['id'] = $_REQUEST['id'] = 5; + + $theme = new RoleInUseThemeStub(); + $controller = roleInUseController($theme); + + expect(fn () => $controller->process())->toThrow(RuntimeException::class, 'alertAndQuit:role_in_use_message'); + + // the refusal uses the lexicon key, and nothing was deleted + expect($theme->quitMessage)->toBe('role_in_use_message') + ->and($this->capsule->table('user_roles')->where('id', 5)->exists())->toBeTrue() + ->and($this->capsule->table('role_permissions')->where('role_id', 5)->exists())->toBeTrue(); +}); + +it('allows deleting a role that no user holds', function () { + // role 5 exists but nobody is assigned to it + $_GET['action'] = $_REQUEST['action'] = 'delete'; + $_GET['id'] = $_REQUEST['id'] = 5; + + $theme = new RoleInUseThemeStub(); + $controller = roleInUseController($theme); + + // site_module_roles / user_role_vars are touched on the success path too + $schema = $this->capsule->getConnection()->getSchemaBuilder(); + $schema->create('site_module_roles', function (Blueprint $t) { + $t->increments('id'); + $t->unsignedInteger('role')->default(0); + }); + $schema->create('user_role_vars', function (Blueprint $t) { + $t->increments('id'); + $t->unsignedInteger('roleid')->default(0); + }); + + @$controller->process(); + + expect($theme->quitMessage)->toBeNull() + ->and($this->capsule->table('user_roles')->where('id', 5)->exists())->toBeFalse() + ->and($this->capsule->table('role_permissions')->where('role_id', 5)->exists())->toBeFalse(); +}); + +test('the delete branch refuses an in-use role before any delete, via the lexicon', function () { + $source = (string) file_get_contents(dirname(__DIR__, 3) . '/src/Controllers/UserRoles/UserRole.php'); + + $delete = strpos($source, "\$_GET['action'] == 'delete'"); + $check = strpos($source, "Models\\UserAttribute::where('role', \$id)->exists()", $delete); + $quit = strpos($source, "alertAndQuit('role_in_use_message')", $delete); + $roleDelete = strpos($source, "Models\\UserRole::query()->where('id', \$id)->delete()", $delete); + + expect($delete)->not->toBeFalse() + ->and($check)->not->toBeFalse() + ->and($quit)->not->toBeFalse() + ->and($roleDelete)->not->toBeFalse() + // the in-use refusal sits before the role is removed + ->and($check)->toBeLessThan($roleDelete) + ->and($quit)->toBeLessThan($roleDelete); +}); + +test('the refusal message is defined in the English lexicon', function () { + $en = (string) file_get_contents(dirname(__DIR__, 3) . '/lang/en/global.php'); + + expect($en)->toContain('$_lang["role_in_use_message"]'); +}); From fe8dd19aed5853a5c23b3eecaad2f2c0fb658938 Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Mon, 9 Nov 2026 13:20:21 +0200 Subject: [PATCH 39/50] fix(updater): preserve Composer authentication during preflight --- AGENTS.md | 5 + core/src/Console/SiteUpdateCommand.php | 23 +- core/src/Services/ComposerEnvironment.php | 130 ++++++++++ .../Unit/Services/ComposerEnvironmentTest.php | 225 ++++++++++++++++++ 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 core/src/Services/ComposerEnvironment.php create mode 100644 core/tests/Unit/Services/ComposerEnvironmentTest.php diff --git a/AGENTS.md b/AGENTS.md index f622b6a77f..69c8bec875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,11 @@ under one policy. Do not add a restore path that skips it: prefixes `custom/` to path repositories and to `extra.merge-plugin.include` / `require`, a list or a single path) and removed again before `moveFiles()`, which would otherwise lay it over the site's own. +- `make:site` scopes Composer authentication through `ComposerEnvironment`: keep the normal + Composer home (explicit, user/XDG or Windows), with `core/composer` only as the no-home fallback. + Its legacy auth file supplies fallback credentials; normal global config/auth, `core/auth.json`, + then `COMPOSER_AUTH` take precedence. Preflight and restore inherit the same environment; + never copy credentials into the archive or shell arguments, and restore the caller's environment. - The merge plugin runs with `"replace": true`: a custom constraint replaces the core's own, so a custom requirement on a core package is checked against the lock like any other (`pins()`). - A restore that fails leaves the installers' update incomplete: the CLI exits with 1 and keeps `install/`, diff --git a/core/src/Console/SiteUpdateCommand.php b/core/src/Console/SiteUpdateCommand.php index d85ee60e6a..f203d14356 100644 --- a/core/src/Console/SiteUpdateCommand.php +++ b/core/src/Console/SiteUpdateCommand.php @@ -4,6 +4,7 @@ use EvolutionCMS\Models\SiteModule; use EvolutionCMS\Models\SiteModuleRole; use EvolutionCMS\Models\SystemSetting; +use EvolutionCMS\Services\ComposerEnvironment; use EvolutionCMS\Services\ComposerVersionSynchronizer; use EvolutionCMS\Services\CustomComposerPackages; use EvolutionCMS\Services\Store\RemoteTransportService; @@ -12,6 +13,8 @@ use Illuminate\Console\Command; /** + * Check and apply a core update while preserving the site's packages and Composer credentials. + * * @see: https://github.com/laravel-zero/foundation/blob/9.x/src/Illuminate/Foundation/Console/ClearCompiledCommand.php */ class SiteUpdateCommand extends Command @@ -184,6 +187,25 @@ public function handle() * @return bool False when the update failed before the site was changed. */ public function startUpdate() + { + // An installed archive can still have an authoritative classmap from before this helper existed. + if (!class_exists(ComposerEnvironment::class)) { + require_once dirname(__DIR__) . '/Services/ComposerEnvironment.php'; + } + + return (new ComposerEnvironment(EVO_CORE_PATH))->run(fn () => $this->applyUpdate()); + } + + /** + * Download and apply the update inside the installed site's Composer environment. + * + * Called by startUpdate() so the archive preflight and the later dependency restore + * inherit identical authentication, with the caller's environment restored afterwards. + * + * @return bool False when the update was refused before files were replaced. + * @since 3.6.0 + */ + protected function applyUpdate() { $evo = evo(); $currentVersion = $evo->getVersionData(); @@ -259,7 +281,6 @@ public function startUpdate() return false; } - putenv('COMPOSER_HOME=' . EVO_CORE_PATH . 'composer'); $customPackageUpdate = $this->preflightComposer( $temp_dir . '/' . $dir . '/core', $customPackageNames, diff --git a/core/src/Services/ComposerEnvironment.php b/core/src/Services/ComposerEnvironment.php new file mode 100644 index 0000000000..aa01c771af --- /dev/null +++ b/core/src/Services/ComposerEnvironment.php @@ -0,0 +1,130 @@ +corePath = rtrim($corePath, '/\\') . '/'; + } + + /** + * Run an update with the same credentials for its preflight and dependency restore. + * + * Precedence is legacy auth, normal global config/auth, project auth, then COMPOSER_AUTH. + * Restore the original process environment even when the update fails or throws. + * No credential file is written or copied into the update archive. + * + * @param callable(): mixed $update Update work whose Composer subprocesses inherit this environment. + * @return mixed The update's result, unchanged. + * @since 3.6.0 + */ + public function run(callable $update): mixed + { + $previous = []; + foreach (['COMPOSER_HOME', 'COMPOSER_AUTH'] as $name) { + $previous[$name] = [getenv($name), array_intersect_key($_SERVER, [$name => true]), array_intersect_key($_ENV, [$name => true])]; + } + + try { + $home = Platform::getEnv('COMPOSER_HOME'); + if ($home) { + Platform::putEnv('COMPOSER_HOME', $home); + } elseif (!Platform::getEnv(Platform::isWindows() ? 'APPDATA' : 'HOME')) { + // FPM/cron may have no user home; keep the existing site-local fallback there. + Platform::putEnv('COMPOSER_HOME', $this->corePath . 'composer'); + } + + $config = new Config(); + $config->merge(['config' => $this->readAuthentication($this->corePath . 'composer/auth.json')]); + try { + $globalConfig = Factory::createConfig(new NullIO(), $this->corePath); + $global = $globalConfig->raw()['config']; + } catch (\Throwable $exception) { + // Composer's JSON diagnostics may contain a line of credentials. + throw new \RuntimeException('Could not load the existing Composer configuration and authentication.'); + } + // Resolve implicit/XDG/Windows homes once so every child sees the same directory. + Platform::putEnv('COMPOSER_HOME', $globalConfig->get('home')); + $config->merge(['config' => array_intersect_key($global, array_flip(self::AUTH_METHODS))]); + $config->merge(['config' => $this->readAuthentication($this->corePath . 'auth.json')]); + + $auth = Platform::getEnv('COMPOSER_AUTH'); + if ($auth !== false && $auth !== '') { + try { + $data = JsonFile::parseJson($auth); + JsonFile::validateJsonSchema('COMPOSER_AUTH', $data, JsonFile::AUTH_SCHEMA); + } catch (\Throwable $exception) { + throw new \RuntimeException('Invalid Composer authentication in COMPOSER_AUTH.'); + } + $config->merge(['config' => $data]); + } + + $authentication = array_filter(array_intersect_key($config->raw()['config'], array_flip(self::AUTH_METHODS))); + Platform::putEnv('COMPOSER_AUTH', json_encode((object) $authentication, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + + return $update(); + } finally { + foreach ($previous as $name => [$value, $server, $env]) { + $value === false ? putenv($name) : putenv($name . '=' . $value); + unset($_SERVER[$name], $_ENV[$name]); + $_SERVER += $server; + $_ENV += $env; + } + } + } + + /** + * Read an optional auth file without exposing its contents in parse errors. + * + * Composer validates the schema and merges host entries, so one host's username + * and password are replaced together rather than recursively mixed with another source. + * + * @param string $path Installed site's authentication file; an absent file contributes nothing. + * @return array Composer authentication sections. + * @since 3.6.0 + */ + private function readAuthentication(string $path): array + { + if (!is_file($path)) { + return []; + } + + try { + $file = new JsonFile($path); + $file->validateSchema(JsonFile::AUTH_SCHEMA); + + return $file->read(); + } catch (\Throwable $exception) { + throw new \RuntimeException('Could not read Composer authentication from ' . $path . '.'); + } + } +} diff --git a/core/tests/Unit/Services/ComposerEnvironmentTest.php b/core/tests/Unit/Services/ComposerEnvironmentTest.php new file mode 100644 index 0000000000..c982491616 --- /dev/null +++ b/core/tests/Unit/Services/ComposerEnvironmentTest.php @@ -0,0 +1,225 @@ +workspace = sys_get_temp_dir() . '/evo-composer-auth-' . bin2hex(random_bytes(8)); + $this->core = $this->workspace . '/site/core'; + $this->archive = $this->workspace . '/unpacked/core'; + $this->home = $this->workspace . '/global'; + foreach ([$this->core . '/composer', $this->archive, $this->home] as $directory) { + mkdir($directory, 0777, true); + } + foreach ([$this->core, $this->archive] as $directory) { + file_put_contents($directory . '/composer.json', '{"name":"test/update","require":{}}'); + } + + $this->environment = []; + foreach (['COMPOSER_HOME', 'COMPOSER_AUTH', 'HOME', 'APPDATA', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME'] as $name) { + $this->environment[$name] = [getenv($name), array_intersect_key($_SERVER, [$name => true]), array_intersect_key($_ENV, [$name => true])]; + } + Platform::putEnv('COMPOSER_HOME', $this->home); + Platform::clearEnv('COMPOSER_AUTH'); +}); + +afterEach(function () { + foreach ($this->environment as $name => [$value, $server, $env]) { + $value === false ? putenv($name) : putenv($name . '=' . $value); + unset($_SERVER[$name], $_ENV[$name]); + $_SERVER += $server; + $_ENV += $env; + } + SiteUpdateCommand::rmdirs($this->workspace); +}); + +test('the unpacked core and the installed core receive the same global project and environment credentials', function () { + file_put_contents($this->home . '/auth.json', json_encode(['http-basic' => [ + 'global.example' => ['username' => 'global', 'password' => 'global-secret'], + 'shared.example' => ['username' => 'old', 'password' => 'old-secret'], + ]])); + $project = json_encode(['http-basic' => [ + 'project.example' => ['username' => 'project', 'password' => 'project-secret'], + 'shared.example' => ['username' => 'project', 'password' => 'project-secret'], + ], 'custom-headers' => ['headers.example' => ['X-Token: header-secret']]]); + file_put_contents($this->core . '/auth.json', $project); + $original = json_encode(['http-basic' => [ + 'shared.example' => ['username' => 'environment', 'password' => 'environment-secret'], + ], 'bearer' => ['token.example' => 'environment-token']]); + Platform::putEnv('COMPOSER_AUTH', $original); + + $result = (new ComposerEnvironment($this->core))->run(function () { + $configs = []; + foreach ([$this->archive, $this->core] as $directory) { + // Use Composer's own project loader, with plugins/scripts disabled and no dependency resolution. + $configs[] = (new Factory())->createComposer(new NullIO(), $directory . '/composer.json', true, $directory, false)->getConfig(); + } + foreach ($configs as $config) { + expect($config->get('home'))->toBe($this->home) + ->and($config->get('http-basic'))->toBe([ + 'global.example' => ['username' => 'global', 'password' => 'global-secret'], + 'shared.example' => ['username' => 'environment', 'password' => 'environment-secret'], + 'project.example' => ['username' => 'project', 'password' => 'project-secret'], + ]) + ->and($config->get('bearer'))->toBe(['token.example' => 'environment-token']) + ->and($config->get('custom-headers'))->toBe(['headers.example' => ['X-Token: header-secret']]); + } + expect(file_exists($this->archive . '/auth.json'))->toBeFalse(); + + return 'update-result'; + }); + + expect($result)->toBe('update-result') + ->and(getenv('COMPOSER_HOME'))->toBe($this->home) + ->and(getenv('COMPOSER_AUTH'))->toBe($original) + ->and($_SERVER['COMPOSER_AUTH'])->toBe($original) + ->and(file_get_contents($this->core . '/auth.json'))->toBe($project) + ->and(file_exists($this->archive . '/auth.json'))->toBeFalse(); +}); + +test('legacy site credentials remain available without overriding the normal global or project credentials', function () { + file_put_contents($this->core . '/composer/auth.json', json_encode(['http-basic' => [ + 'legacy.example' => ['username' => 'legacy', 'password' => 'legacy-secret'], + 'shared.example' => ['username' => 'legacy', 'password' => 'legacy-secret'], + ]])); + file_put_contents($this->home . '/auth.json', json_encode(['http-basic' => [ + 'shared.example' => ['username' => 'global', 'password' => 'global-secret'], + ]])); + + (new ComposerEnvironment($this->core))->run(function () { + $auth = json_decode(getenv('COMPOSER_AUTH'), true, flags: JSON_THROW_ON_ERROR); + expect($auth['http-basic'])->toBe([ + 'legacy.example' => ['username' => 'legacy', 'password' => 'legacy-secret'], + 'shared.example' => ['username' => 'global', 'password' => 'global-secret'], + ])->and(getenv('COMPOSER_HOME'))->toBe($this->home); + }); +}); + +test('project authentication overrides global authentication when there is no environment override', function () { + file_put_contents($this->home . '/config.json', '{"config":{"bearer":{"shared.example":"global-config-token"}}}'); + file_put_contents($this->home . '/auth.json', '{"bearer":{"shared.example":"global-auth-token","global.example":"global-token"}}'); + file_put_contents($this->core . '/auth.json', '{"bearer":{"shared.example":"project-token"}}'); + + (new ComposerEnvironment($this->core))->run(function () { + expect(json_decode(getenv('COMPOSER_AUTH'), true)['bearer'])->toBe([ + 'shared.example' => 'project-token', + 'global.example' => 'global-token', + ]); + }); +}); + +test('startUpdate scopes authentication even with an authoritative classmap that predates the helper', function () { + file_put_contents($this->core . '/auth.json', '{"bearer":{"project.example":"project-token"}}'); + $probe = $this->workspace . '/update.php'; + file_put_contents($probe, 'getClassMap(); unset($map[\\EvolutionCMS\\Services\\ComposerEnvironment::class]);' + . ' $loader->unregister(); $oldLoader = new \\Composer\\Autoload\\ClassLoader();' + . ' $oldLoader->addClassMap($map); $oldLoader->setClassMapAuthoritative(true); $oldLoader->register();' + . ' define("EVO_CORE_PATH", ' . var_export($this->core . '/', true) . ');' + . ' $command = new class extends \\EvolutionCMS\\Console\\SiteUpdateCommand {' + . ' protected function applyUpdate() {' + . ' $auth = json_decode(getenv("COMPOSER_AUTH"), true);' + . ' if (($auth["bearer"]["project.example"] ?? null) !== "project-token") { throw new \\RuntimeException("Missing project auth"); }' + . ' return false; } };' + . ' $result = $command->startUpdate();' + . ' echo json_encode([$result, getenv("COMPOSER_AUTH"), getenv("COMPOSER_HOME")]);'); + + expect(json_decode(evoRunPhp($probe), true, flags: JSON_THROW_ON_ERROR)) + ->toBe([false, false, $this->home]); +}); + +test('implicit Composer home is resolved by Composer rather than replaced by core composer', function () { + Platform::clearEnv('COMPOSER_HOME'); + Platform::putEnv(Platform::isWindows() ? 'APPDATA' : 'HOME', $this->workspace . '/user'); + Platform::putEnv('XDG_CONFIG_HOME', $this->workspace . '/xdg'); + $home = Factory::createConfig(new NullIO())->get('home'); + file_put_contents($home . '/auth.json', '{"bearer":{"implicit.example":"implicit-token"}}'); + + (new ComposerEnvironment($this->core))->run(function () use ($home) { + expect(getenv('COMPOSER_HOME'))->toBe($home) + ->and(json_decode(getenv('COMPOSER_AUTH'), true)['bearer'])->toBe(['implicit.example' => 'implicit-token']); + }); + + expect(getenv('COMPOSER_HOME'))->toBeFalse(); +}); + +test('an environment without a user home retains the site local Composer home fallback', function () { + foreach (['COMPOSER_HOME', 'HOME', 'APPDATA'] as $name) { + Platform::clearEnv($name); + } + file_put_contents($this->core . '/composer/auth.json', '{"bearer":{"legacy.example":"legacy-token"}}'); + + (new ComposerEnvironment($this->core))->run(function () { + expect(getenv('COMPOSER_HOME'))->toBe($this->core . '/composer') + ->and(Factory::createConfig(new NullIO())->get('bearer'))->toBe(['legacy.example' => 'legacy-token']); + }); + + expect(getenv('COMPOSER_HOME'))->toBeFalse() + ->and(getenv('COMPOSER_AUTH'))->toBeFalse() + ->and(array_key_exists('COMPOSER_HOME', $_SERVER))->toBeFalse(); +}); + +test('the environment inherited by child processes is restored after a failure', function (bool $throws) { + // A web server or dotenv may set runtime globals without exporting them to subprocesses. + putenv('COMPOSER_HOME'); + $_SERVER['COMPOSER_HOME'] = $this->home; + $_ENV['COMPOSER_HOME'] = 'distinct-env-home'; + $auth = '{"bearer":{"env.example":"synthetic-token"}}'; + putenv('COMPOSER_AUTH='); + $_SERVER['COMPOSER_AUTH'] = $auth; + unset($_ENV['COMPOSER_AUTH']); + + try { + $result = (new ComposerEnvironment($this->core))->run(function () use ($throws) { + $probe = $this->workspace . '/child.php'; + file_put_contents($probe, 'toBe([$this->home, ['bearer' => ['env.example' => 'synthetic-token']]]); + if ($throws) { + throw new RuntimeException('Synthetic update failure.'); + } + + return false; + }); + expect($throws)->toBeFalse()->and($result)->toBeFalse(); + } catch (RuntimeException $exception) { + expect($throws)->toBeTrue()->and($exception->getMessage())->toBe('Synthetic update failure.'); + } + + expect(getenv('COMPOSER_HOME'))->toBeFalse() + ->and($_SERVER['COMPOSER_HOME'])->toBe($this->home) + ->and($_ENV['COMPOSER_HOME'])->toBe('distinct-env-home') + ->and(getenv('COMPOSER_AUTH'))->toBe('') + ->and($_SERVER['COMPOSER_AUTH'])->toBe($auth) + ->and(array_key_exists('COMPOSER_AUTH', $_ENV))->toBeFalse(); +})->with([false, true]); + +test('invalid authentication is refused without printing credentials or running the update', function (string $source) { + $secret = 'synthetic-secret-that-must-not-be-printed'; + $invalid = '{"http-basic":{"repo.example":{"password":"' . $secret . '"} BROKEN'; + if ($source === 'COMPOSER_AUTH') { + Platform::putEnv($source, $invalid); + } else { + file_put_contents($source === 'global' ? $this->home . '/auth.json' : $this->core . '/' . $source, $invalid); + } + + $ran = false; + $before = getenv('COMPOSER_AUTH'); + try { + (new ComposerEnvironment($this->core))->run(function () use (&$ran) { + $ran = true; + }); + $this->fail('Invalid authentication must stop the update.'); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toContain('Composer')->not->toContain($secret) + ->and($exception->getPrevious())->toBeNull(); + } + + expect($ran)->toBeFalse() + ->and(getenv('COMPOSER_AUTH'))->toBe($before) + ->and(getenv('COMPOSER_HOME'))->toBe($this->home) + ->and(file_exists($this->archive . '/auth.json'))->toBeFalse(); +})->with(['auth.json', 'composer/auth.json', 'global', 'COMPOSER_AUTH']); From e07084bfe5407c07e2358b4b1f7e1fc414afcdf4 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 13:30:37 +0100 Subject: [PATCH 40/50] fix(manager): judge the editor preview by parsed origin, reject other schemes The Preview button decided sandbox vs. window by a text prefix of the site URL, so same-origin links such as /page, //host/page, a different letter case or a path outside a subdirectory installation opened unsandboxed, and a javascript: weblink ran in a same-origin window. preview-target-helper.js now parses the target against the site URL, sandboxes anything on the site's or the manager's origin, opens only external http(s) links (noopener) and refuses every other scheme. Co-Authored-By: Claude Opus 5.5 --- core/lang/az/global.php | 1 + core/lang/be/global.php | 1 + core/lang/bg/global.php | 1 + core/lang/cs/global.php | 1 + core/lang/da/global.php | 1 + core/lang/de/global.php | 1 + core/lang/en/global.php | 1 + core/lang/es/global.php | 1 + core/lang/et/global.php | 1 + core/lang/fa/global.php | 1 + core/lang/fi/global.php | 1 + core/lang/fr/global.php | 1 + core/lang/he/global.php | 1 + core/lang/hy/global.php | 1 + core/lang/it/global.php | 1 + core/lang/ja/global.php | 1 + core/lang/ka/global.php | 1 + core/lang/kk/global.php | 1 + core/lang/ky/global.php | 1 + core/lang/lt/global.php | 1 + core/lang/lv/global.php | 1 + core/lang/nl/global.php | 1 + core/lang/nn/global.php | 1 + core/lang/pl/global.php | 1 + core/lang/pt/global.php | 1 + core/lang/ro/global.php | 1 + core/lang/sk/global.php | 1 + core/lang/sv/global.php | 1 + core/lang/tg/global.php | 1 + core/lang/tk/global.php | 1 + core/lang/uk/global.php | 1 + core/lang/uz/global.php | 1 + core/lang/zh/global.php | 1 + .../Manager/ReferenceTypeSwitchFlowTest.php | 5 +- .../Security/DocumentPreviewSandboxTest.php | 27 +++- manager/actions/mutate_content.dynamic.php | 22 +-- manager/media/script/preview-target-helper.js | 61 ++++++++ .../tests/preview-target-helper.test.js | 132 ++++++++++++++++++ 38 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 manager/media/script/preview-target-helper.js create mode 100644 manager/media/script/tests/preview-target-helper.test.js diff --git a/core/lang/az/global.php b/core/lang/az/global.php index 26351ef9e8..b5b47e12a2 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -1067,6 +1067,7 @@ $_lang["preview"] = 'Önizləmə'; $_lang["preview_msg"] = 'Bu, son saxlanılan dəyişikliklərinizin önizləməsidir. Buraya klikləyin:
Saxla və Yenilə cari dəyişikliklərinizi.'; $_lang["preview_resource"] = 'Resursun önizləməsi'; +$_lang["preview_unsupported_url"] = 'Bu keçidə önizləmə mümkün deyil: idarəetmə panelindən yalnız http və https ünvanları açılır.'; $_lang["private"] = 'Şəxsi'; $_lang["public"] = 'İctimai'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index e0f041c3c9..27d8c244c8 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -1053,6 +1053,7 @@ $_lang["preview"] = 'Папярэдні прагляд'; $_lang["preview_msg"] = 'Гэта папярэдні прагляд вашых апошніх захаваных зменаў. Націсніце тут, каб захаваць і абнавіць бягучыя змены'; $_lang["preview_resource"] = 'Рэсурс для папярэдняга прагляду'; +$_lang["preview_unsupported_url"] = 'Гэтую спасылку немагчыма папярэдне праглядзець: з мэнэджэра адкрываюцца толькі адрасы http і https.'; $_lang["private"] = 'Прыватны'; $_lang["public"] = 'Грамадскі'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 3d3cb3875c..fe6bf730c5 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -679,6 +679,7 @@ $_lang["preview"] = 'Преглед'; $_lang["preview_msg"] = 'Това е преглед на последно съхранените промени. Щракнете Съхранение и Обновяване на текущите промени'; $_lang["preview_resource"] = 'Преглед на документ'; +$_lang["preview_unsupported_url"] = 'Тази връзка не може да бъде прегледана: от мениджъра се отварят само адреси с http и https.'; $_lang["private"] = 'Частен'; $_lang["public"] = 'Публичен'; $_lang["publish_date"] = 'Дата на публикуване'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 251bb590b6..014d3e3e32 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -679,6 +679,7 @@ $_lang["preview"] = 'Náhled'; $_lang["preview_msg"] = 'Tohle je náhled na vaše poslední uložené změny. Kliknutím zde na Uložit a obnovit uložíte vaše současné změny'; $_lang["preview_resource"] = 'Náhled dokumentu'; +$_lang["preview_unsupported_url"] = 'Tento odkaz nelze zobrazit v náhledu: ze správce se otevírají pouze adresy http a https.'; $_lang["private"] = 'Soukromý'; $_lang["public"] = 'Veřejný'; $_lang["publish_date"] = 'Datum publikování'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index ab3f308077..954866b218 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -680,6 +680,7 @@ $_lang["preview"] = 'Preview'; $_lang["preview_msg"] = 'Dette er et preview af de sidste gemte ændringer. Klik her for at gemme og opdatere ændringerne'; $_lang["preview_resource"] = 'Preview ressourcen'; +$_lang["preview_unsupported_url"] = 'Dette link kan ikke vises som preview: kun http- og https-adresser åbnes fra administrationen.'; $_lang["private"] = 'Privat'; $_lang["public"] = 'Offentligt'; $_lang["publish_date"] = 'Publiceringsdato'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index 196daa3880..005f63bf2c 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -722,6 +722,7 @@ $_lang["preview"] = 'Vorschau'; $_lang["preview_msg"] = 'Dies ist eine Vorschau Ihrer zuletzt gespeicherten Änderungen. Klicken Sie Speichern und aktualisieren um Ihren aktuellen Änderungen zu speichern.'; $_lang["preview_resource"] = 'Ressource-Vorschau'; +$_lang["preview_unsupported_url"] = 'Dieser Link kann nicht in der Vorschau angezeigt werden: Aus dem Manager werden nur http- und https-Adressen geöffnet.'; $_lang["private"] = 'Privat'; $_lang["public"] = 'Öffentlich'; $_lang["publish_date"] = 'Veröffentlichungstermin'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 96c6eee69b..fe46e31f34 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Preview'; $_lang["preview_msg"] = 'This is a preview of your last saved changes. Click here to Save and Refresh your current changes'; $_lang["preview_resource"] = 'Preview Resource'; +$_lang["preview_unsupported_url"] = 'This link cannot be previewed: only http and https addresses open from the manager.'; $_lang["private"] = 'Private'; $_lang["public"] = 'Public'; $_lang["publish_date"] = 'Publish Date'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index aff1897d3b..2e207d5cf7 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -724,6 +724,7 @@ $_lang["preview"] = 'Previsualizar'; $_lang["preview_msg"] = 'Esta es una previsualización de tus últimos cambios guardados. Haz clic aquí para Guardar y Recargar tus cambios actuales'; $_lang["preview_resource"] = 'Previsualizar documento'; +$_lang["preview_unsupported_url"] = 'Este enlace no se puede previsualizar: desde el administrador solo se abren direcciones http y https.'; $_lang["private"] = 'Privado'; $_lang["public"] = 'Público'; $_lang["publish_date"] = 'Fecha de publicación'; diff --git a/core/lang/et/global.php b/core/lang/et/global.php index 519b563b4d..2e875999b3 100644 --- a/core/lang/et/global.php +++ b/core/lang/et/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Eelvaade'; $_lang["preview_msg"] = 'See on viimati salvestatud muudatuste eelvaade. Praeguste muudatuste salvestamiseks ja värskendamiseks klõpsa siin'; $_lang["preview_resource"] = 'Ressursi eelvaade'; +$_lang["preview_unsupported_url"] = 'Seda linki ei saa eelvaates avada: haldusliidesest avanevad ainult http- ja https-aadressid.'; $_lang["private"] = 'Privaatne'; $_lang["public"] = 'Avalik'; $_lang["publish_date"] = 'Avaldamise kuupäev'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 4cc02c75c6..83824f96e4 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -680,6 +680,7 @@ $_lang["preview"] = 'نمایش وبگاه'; $_lang["preview_msg"] = 'این پیش نمایش آخرین تغییرات ذخیره شده ی شماست. برای ذخیره نهایی و بازخوانی تغییرات جدیداینجا کلیک کنید'; $_lang["preview_resource"] = 'نمایش پرونده'; +$_lang["preview_unsupported_url"] = 'این پیوند قابل نمایش نیست: از مدیر فقط نشانی‌های http و https باز می‌شوند.'; $_lang["private"] = 'خصوصی'; $_lang["public"] = 'عمومی'; $_lang["publish_date"] = 'تاریخ انتشار'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index a8c64f9a8c..ca5f820165 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -679,6 +679,7 @@ $_lang["preview"] = 'Esikatselu'; $_lang["preview_msg"] = 'Esikatselunäkymä viimeksi tallennetuista muutoksista. Napsauta tästä tallentaaksesi ja päivittääksesi tämän hetkiset muutokset'; $_lang["preview_resource"] = 'Esikatsele'; +$_lang["preview_unsupported_url"] = 'Tätä linkkiä ei voi esikatsella: ylläpidosta avautuvat vain http- ja https-osoitteet.'; $_lang["private"] = 'Yksityinen'; $_lang["public"] = 'Julkinen'; $_lang["publish_date"] = 'Julkaisupäiväys'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index ed9e413690..4bb640c781 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -682,6 +682,7 @@ $_lang["preview"] = 'Prévisualiser'; $_lang["preview_msg"] = 'Voici la prévisualisation de vos derniers changements enregistrés. Cliquer ici pour Enregistrer et Afficher vos modifications actuelles'; $_lang["preview_resource"] = 'Prévisualiser'; +$_lang["preview_unsupported_url"] = 'Ce lien ne peut pas être prévisualisé : seules les adresses http et https s\'ouvrent depuis le gestionnaire.'; $_lang["private"] = 'Privé'; $_lang["public"] = 'Public'; $_lang["publish_date"] = 'Date de publication'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index c65b26abdb..6bb5bc42ee 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -680,6 +680,7 @@ $_lang["preview"] = 'תצוגה מקדימה'; $_lang["preview_msg"] = 'This is a preview of your last saved changes. Click here to Save and Refresh your current changes'; $_lang["preview_resource"] = 'תצוגה מקדימה למסמך'; +$_lang["preview_unsupported_url"] = 'לא ניתן להציג תצוגה מקדימה של קישור זה: רק כתובות http ו-https נפתחות מתוך המנהל.'; $_lang["private"] = 'פרטי'; $_lang["public"] = 'ציבורי'; $_lang["publish_date"] = 'תאריך פרסום'; diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php index 5a678f4cce..8bd2420b58 100644 --- a/core/lang/hy/global.php +++ b/core/lang/hy/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Նախադիտում'; $_lang["preview_msg"] = 'Սա ձեր վերջին պահված փոփոխությունների նախադիտումն է։ Սեղմեք այստեղ՝ ընթացիկ փոփոխությունները պահելու և թարմացնելու համար'; $_lang["preview_resource"] = 'Նախադիտել ռեսուրսը'; +$_lang["preview_unsupported_url"] = 'Այս հղումը հնարավոր չէ նախադիտել։ Կառավարման վահանակից բացվում են միայն http և https հասցեները։'; $_lang["private"] = 'Մասնավոր'; $_lang["public"] = 'Հանրային'; $_lang["publish_date"] = 'Հրապարակման ամսաթիվ'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index 62d31ff99c..7866da5612 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -724,6 +724,7 @@ $_lang["preview"] = 'Anteprima'; $_lang["preview_msg"] = 'Questa è solo l\'anteprima delle modifiche. Clicca su Salva e Aggiorna per salvare le modifiche'; $_lang["preview_resource"] = 'Anteprima Risorsa'; +$_lang["preview_unsupported_url"] = 'Questo link non può essere visualizzato in anteprima: dal Manager si aprono solo indirizzi http e https.'; $_lang["private"] = 'Privato'; $_lang["public"] = 'Pubblico'; $_lang["publish_date"] = 'Data di pubblicazione'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 9f4787ee04..5b78197c12 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -749,6 +749,7 @@ $_lang["preview"] = 'プレビュー'; $_lang["preview_msg"] = 'ここには最後に更新した内容をプレビューしています。 更新と再描画を行うにはここをクリックしてください。'; $_lang["preview_resource"] = 'プレビュー(別窓)'; +$_lang["preview_unsupported_url"] = 'このリンクはプレビューできません。管理画面から開けるのは http と https のアドレスのみです。'; $_lang["private"] = 'グループ設定'; $_lang["public"] = 'グループ制限なし'; $_lang["publish_date"] = '公開日時'; diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php index f59adc9cce..96b7555588 100644 --- a/core/lang/ka/global.php +++ b/core/lang/ka/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'წინასწარი ნახვა'; $_lang["preview_msg"] = 'ეს არის ბოლოს შენახული ცვლილებების წინასწარი ნახვა. მიმდინარე ცვლილებების შესანახად და განსაახლებლად დააწკაპუნეთ აქ'; $_lang["preview_resource"] = 'რესურსის წინასწარი ნახვა'; +$_lang["preview_unsupported_url"] = 'ამ ბმულის წინასწარი ნახვა შეუძლებელია: მართვის პანელიდან იხსნება მხოლოდ http და https მისამართები.'; $_lang["private"] = 'პირადი'; $_lang["public"] = 'საჯარო'; $_lang["publish_date"] = 'გამოქვეყნების თარიღი'; diff --git a/core/lang/kk/global.php b/core/lang/kk/global.php index fe39072d02..c1bd332c2b 100644 --- a/core/lang/kk/global.php +++ b/core/lang/kk/global.php @@ -764,6 +764,7 @@ $_lang["preview"] = 'Алдын ала қарау'; $_lang["preview_msg"] = 'Бұл соңғы сақталған өзгерістердің көрінісі. Ағымдағы өзгерістерді сақтау және жаңарту үшін басыңыз'; $_lang["preview_resource"] = 'Ресурсты алдын ала қарау'; +$_lang["preview_unsupported_url"] = 'Бұл сілтемені алдын ала қарау мүмкін емес: басқару тақтасынан тек http және https мекенжайлары ашылады.'; $_lang["private"] = 'Жеке'; $_lang["public"] = 'Жалпыға ашық'; $_lang["publish_date"] = 'Жариялау күні'; diff --git a/core/lang/ky/global.php b/core/lang/ky/global.php index d93a1aeaaa..663f154c10 100644 --- a/core/lang/ky/global.php +++ b/core/lang/ky/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Алдын ала көрүү'; $_lang["preview_msg"] = 'Акыркы сакталган өзгөртүүлөр көрсөтүлүүдө. Учурдагы өзгөртүүлөрдү сактап жаңыртуу үчүн басыңыз'; $_lang["preview_resource"] = 'Ресурсту алдын ала көрүү'; +$_lang["preview_unsupported_url"] = 'Бул шилтемени алдын ала көрүү мүмкүн эмес: башкаруу панелинен http жана https даректери гана ачылат.'; $_lang["private"] = 'Жеке'; $_lang["public"] = 'Жалпыга ачык'; $_lang["publish_date"] = 'Жарыялоо күнү'; diff --git a/core/lang/lt/global.php b/core/lang/lt/global.php index 3e899fe468..29ed367dcf 100644 --- a/core/lang/lt/global.php +++ b/core/lang/lt/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Peržiūra'; $_lang["preview_msg"] = 'Tai paskutinių išsaugotų pakeitimų peržiūra. Spustelėkite čia, kad išsaugotumėte ir atnaujintumėte dabartinius pakeitimus'; $_lang["preview_resource"] = 'Peržiūrėti išteklių'; +$_lang["preview_unsupported_url"] = 'Šios nuorodos peržiūrėti negalima: iš valdymo skydelio atidaromi tik http ir https adresai.'; $_lang["private"] = 'Privatus'; $_lang["public"] = 'Viešas'; $_lang["publish_date"] = 'Paskelbimo data'; diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php index edeec426bd..55252b9a80 100644 --- a/core/lang/lv/global.php +++ b/core/lang/lv/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Priekšskatījums'; $_lang["preview_msg"] = 'Šis ir pēdējo saglabāto izmaiņu priekšskatījums. Noklikšķiniet šeit, lai saglabātu un atsvaidzinātu pašreizējās izmaiņas'; $_lang["preview_resource"] = 'Priekšskatīt resursu'; +$_lang["preview_unsupported_url"] = 'Šo saiti nevar priekšskatīt: no pārvaldības paneļa tiek atvērtas tikai http un https adreses.'; $_lang["private"] = 'Privāts'; $_lang["public"] = 'Publisks'; $_lang["publish_date"] = 'Publicēšanas datums'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 0c09b6214f..3d30548745 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -712,6 +712,7 @@ $_lang["preview"] = 'Voorbeeld'; $_lang["preview_msg"] = 'Dit is een voorbeeld van uw laatst opgeslagen wijzigingen. Klik hier om uw huidige wijzigingen op te slaan en te vernieuwen.'; $_lang["preview_resource"] = 'Pagina voorbeeld'; +$_lang["preview_unsupported_url"] = 'Van deze link kan geen voorbeeld worden getoond: vanuit de Manager worden alleen http- en https-adressen geopend.'; $_lang["private"] = 'Gesloten'; $_lang["public"] = 'Open'; $_lang["publish_date"] = 'Publicatiedatum'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 29c85219be..42691b316f 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -679,6 +679,7 @@ $_lang["preview"] = 'Forhåndsvis'; $_lang["preview_msg"] = 'Dette er en forhåndsvisning av dine siste endringer. Klikk her for å lagre og laste om dine nåværende endringer'; $_lang["preview_resource"] = 'Preview Resource'; +$_lang["preview_unsupported_url"] = 'Denne lenken kan ikke forhåndsvises: bare http- og https-adresser åpnes fra håndtereren.'; $_lang["private"] = 'Privat'; $_lang["public"] = 'Offentlig'; $_lang["publish_date"] = 'Publiseringsdato'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index c9a169b2bf..f3c0a78bf1 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -725,6 +725,7 @@ $_lang["preview"] = 'Podgląd'; $_lang["preview_msg"] = 'To jest podgląd twoich ostatnio zapisanych zmian. Kliknij tutaj aby zapisać i odświeżyć obecne zmiany'; $_lang["preview_resource"] = 'Podgląd dokumentu'; +$_lang["preview_unsupported_url"] = 'Tego linku nie można wyświetlić w podglądzie: z menedżera otwierane są tylko adresy http i https.'; $_lang["private"] = 'Prywatny'; $_lang["public"] = 'Publiczny'; $_lang["publish_date"] = 'Data publikacji'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index d1bbf03eaa..9b04c0eae2 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -679,6 +679,7 @@ $_lang["preview"] = 'Prever Site'; $_lang["preview_msg"] = 'Esta é uma pré-visualização das últimas alterações guardadas. Clique aqui para Guardar e actualizar as alterações mais recentes'; $_lang["preview_resource"] = 'Prever documento'; +$_lang["preview_unsupported_url"] = 'Esta ligação não pode ser pré-visualizada: a partir do administrador só se abrem endereços http e https.'; $_lang["private"] = 'Privado'; $_lang["public"] = 'Público'; $_lang["publish_date"] = 'Data de Publicação'; diff --git a/core/lang/ro/global.php b/core/lang/ro/global.php index 3035faaf3e..f0f4870424 100644 --- a/core/lang/ro/global.php +++ b/core/lang/ro/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Previzualizare'; $_lang["preview_msg"] = 'Aceasta este o previzualizare a ultimelor modificări salvate. Apăsați aici pentru a salva și reîmprospăta modificările curente'; $_lang["preview_resource"] = 'Previzualizează resursa'; +$_lang["preview_unsupported_url"] = 'Acest link nu poate fi previzualizat: din panoul de administrare se deschid doar adrese http și https.'; $_lang["private"] = 'Privat'; $_lang["public"] = 'Public'; $_lang["publish_date"] = 'Data publicării'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 002a8bbe48..44cb02c722 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Náhľad'; $_lang["preview_msg"] = 'Náhľad posledných zmien. Uložiť a obnoviť posledné zmeny'; $_lang["preview_resource"] = 'Náhľad'; +$_lang["preview_unsupported_url"] = 'Tento odkaz nie je možné zobraziť v náhľade: z manažéra sa otvárajú iba adresy http a https.'; $_lang["private"] = 'Súkromný'; $_lang["public"] = 'Dostupný pre všetkých'; $_lang["publish_date"] = 'Dátum publikovania'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index de6aada43b..d1e3c8313f 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -680,6 +680,7 @@ $_lang["preview"] = 'Förhandsgranska'; $_lang["preview_msg"] = 'Detta är en förhandsgranskning av dina senast sparade ändringar. Klicka här för att spara och ladda om dina nuvarande ändringar'; $_lang["preview_resource"] = 'Förhandsgranska'; +$_lang["preview_unsupported_url"] = 'Den här länken kan inte förhandsgranskas: endast http- och https-adresser öppnas från hanteraren.'; $_lang["private"] = 'Privat'; $_lang["public"] = 'Offentlig'; $_lang["publish_date"] = 'Publiceringsdatum'; diff --git a/core/lang/tg/global.php b/core/lang/tg/global.php index 87bfbc7db0..0def22af29 100644 --- a/core/lang/tg/global.php +++ b/core/lang/tg/global.php @@ -738,6 +738,7 @@ $_lang["preview"] = 'Пешнамоиш'; $_lang["preview_msg"] = 'Ин пешнамоиши тағйироти охирини захирашуда аст. Барои тағйироти ҷорӣ Захира ва навсозӣ-ро пахш кунед'; $_lang["preview_resource"] = 'Пешнамоиши захира'; +$_lang["preview_unsupported_url"] = 'Ин истинодро пешнамоиш кардан ғайриимкон аст: аз панели идоракунӣ танҳо суроғаҳои http ва https кушода мешаванд.'; $_lang["private"] = 'Хусусӣ'; $_lang["public"] = 'Умумӣ'; $_lang["publish_date"] = 'Санаи нашр'; diff --git a/core/lang/tk/global.php b/core/lang/tk/global.php index 37c8a236a4..23c5d7d949 100644 --- a/core/lang/tk/global.php +++ b/core/lang/tk/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Öňünden gör'; $_lang["preview_msg"] = 'Bu soňky ýatda saklanan üýtgetmeleriňiziň deslapky görnüşidir. Häzirki üýtgetmeleriňizi ýatda saklamak we täzelemek üçin şu ýere basyň'; $_lang["preview_resource"] = 'Resursy öňünden gör'; +$_lang["preview_unsupported_url"] = 'Bu baglanyşygy öňünden görüp bolmaýar: dolandyryş panelinden diňe http we https salgylary açylýar.'; $_lang["private"] = 'Hususy'; $_lang["public"] = 'Jemgyýetçilik'; $_lang["publish_date"] = 'Neşir senesi'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 9418309233..ffe969b699 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -750,6 +750,7 @@ $_lang["preview"] = 'Перегляд'; $_lang["preview_msg"] = 'Перегляд останніх змін. Зберегти і оновити останні зміни'; $_lang["preview_resource"] = 'Перегляд'; +$_lang["preview_unsupported_url"] = 'Це посилання неможливо переглянути: з менеджера відкриваються лише адреси http і https.'; $_lang["private"] = 'Особистий'; $_lang["public"] = 'Доступний для всіх'; $_lang["publish_date"] = 'Дата публікації'; diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php index 7e00d7c075..3d725ea13e 100644 --- a/core/lang/uz/global.php +++ b/core/lang/uz/global.php @@ -743,6 +743,7 @@ $_lang["preview"] = 'Oldindan ko‘rish'; $_lang["preview_msg"] = 'Bu oxirgi saqlangan o‘zgarishlaringizning ko‘rinishi. Joriy o‘zgarishlarni saqlash va yangilash uchun shu yerni bosing'; $_lang["preview_resource"] = 'Resursni oldindan ko‘rish'; +$_lang["preview_unsupported_url"] = 'Bu havolani oldindan ko‘rib bo‘lmaydi: boshqaruv panelidan faqat http va https manzillari ochiladi.'; $_lang["private"] = 'Shaxsiy'; $_lang["public"] = 'Ommaviy'; $_lang["publish_date"] = 'Nashr sanasi'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 91448fb180..23e6f898be 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -680,6 +680,7 @@ $_lang["preview"] = '预览'; $_lang["preview_msg"] = '这个是你最后一次改动的预览. 点击这里 保存刷新你现在的页面'; $_lang["preview_resource"] = 'Preview Resource'; +$_lang["preview_unsupported_url"] = '无法预览此链接:后台管理只能打开 http 和 https 地址。'; $_lang["private"] = '私有的'; $_lang["public"] = '公共的'; $_lang["publish_date"] = 'Publish Date'; diff --git a/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php b/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php index 205726e9a5..8e3d011df7 100644 --- a/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php +++ b/core/tests/Unit/Manager/ReferenceTypeSwitchFlowTest.php @@ -48,6 +48,7 @@ it('previews a site-relative weblink or a document id on the site, not under the manager', function () { $form = file_get_contents(dirname(__DIR__, 4) . '/manager/actions/mutate_content.dynamic.php'); - expect($form)->toContain("previewUrl = site + 'index.php?id=' + rawUrl;"); - expect($form)->toContain('? rawUrl : site + rawUrl;'); + // The weblink is resolved against the site URL; the cases live in preview-target-helper.test.js. + expect($form)->toContain('target = rawUrl;') + ->and($form)->toContain('evoPreviewTargetHelper.resolve(target, site, window.location.href)'); }); diff --git a/core/tests/Unit/Security/DocumentPreviewSandboxTest.php b/core/tests/Unit/Security/DocumentPreviewSandboxTest.php index 290bef0501..eaa1efd11f 100644 --- a/core/tests/Unit/Security/DocumentPreviewSandboxTest.php +++ b/core/tests/Unit/Security/DocumentPreviewSandboxTest.php @@ -24,14 +24,33 @@ | The editor's "Preview" button used to window.open() the raw same-origin front-end URL in a new | window, where an editor's inline content ran with the previewing manager's session. It now opens | same-origin content in the same opaque-origin sandbox the info page uses; only external reference -| targets still open a plain window. These pin the button to the sandbox. +| targets still open a plain window. Whether a target is same-origin is decided on the parsed URL by +| manager/media/script/preview-target-helper.js, whose cases (relative and absolute same-origin +| links, a subdirectory installation, external links, rejected schemes) live in +| manager/media/script/tests/preview-target-helper.test.js. These pin the button to the helper. */ -it('routes a same-origin editor preview through the sandbox, not a raw window', function () { +it('routes the editor preview through the origin helper, not a text prefix', function () { $source = file_get_contents(dirname(__DIR__, 4) . '/manager/actions/mutate_content.dynamic.php'); - expect($source)->toContain('openSandboxedPreview(previewUrl)') - ->and($source)->toContain("previewUrl.lastIndexOf(site, 0) === 0"); + expect($source)->toContain("media/script/preview-target-helper.js')") + ->and($source)->toContain('var preview = evoPreviewTargetHelper.resolve(target, site, window.location.href);') + ->and($source)->toContain("if (preview.mode === 'sandbox') {\n openSandboxedPreview(preview.url);") + ->and($source)->toContain("window.open(preview.url, 'previeWin', 'noopener,noreferrer');") + ->and($source)->not->toContain('lastIndexOf(site') + ->and($source)->not->toContain('window.open(previewUrl'); +}); + +it('opens only http(s) targets and sandboxes every same-origin one', function () { + $node = (new Symfony\Component\Process\ExecutableFinder())->find('node'); + if ($node === null) { + $this->markTestSkipped('Node.js is not installed.'); + } + + $test = dirname(__DIR__, 4) . '/manager/media/script/tests/preview-target-helper.test.js'; + $output = evoRunCommand(escapeshellarg($node) . ' --test ' . escapeshellarg($test) . ' 2>&1', $exitCode); + + expect($exitCode)->toBe(0, $output); }); it('sandboxes the editor preview iframe without allow-same-origin or allow-top-navigation', function () { diff --git a/manager/actions/mutate_content.dynamic.php b/manager/actions/mutate_content.dynamic.php index 141df81e1b..d7f6b64783 100644 --- a/manager/actions/mutate_content.dynamic.php +++ b/manager/actions/mutate_content.dynamic.php @@ -168,6 +168,7 @@ + ', + 'vbscript:msgbox(1)', + 'blob:https://example.com/uuid', + 'file:///etc/passwd', + 'about:blank', + 'mailto:user@example.com', + 'ftp://example.com/file', + 'http://', + 'https://exa mple.com/', + '', + ' ', + null, + undefined, + ]) { + assert.deepEqual(sandboxed(target), { mode: 'reject', url: '' }, String(target)); + } + + assert.equal(helper.resolve('page', 'not a url').mode, 'reject'); +}); From d867bbd4b3c7a8a6b0a1fc8c474876484a42fc75 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 13:53:12 +0100 Subject: [PATCH 41/50] fix(acl): test the role delete guards with an acting manager The delete now asks delete_role and the reach guard before the in-use rule, so the behavioral harness supplies getCore() with the actor's permissions, the session role and the permissions table. Covers an in-use role (refused, permissions kept), an unused role (deleted), a missing delete_role and a role beyond the actor's reach. Co-Authored-By: Claude Opus 5.5 --- .../Unit/Security/RoleInUseDeleteTest.php | 129 ++++++++++++++---- 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/core/tests/Unit/Security/RoleInUseDeleteTest.php b/core/tests/Unit/Security/RoleInUseDeleteTest.php index b2fc1c923c..3a69dea36f 100644 --- a/core/tests/Unit/Security/RoleInUseDeleteTest.php +++ b/core/tests/Unit/Security/RoleInUseDeleteTest.php @@ -19,11 +19,34 @@ | */ +/** The acting manager: hasPermission() answers from its own keys, as Core does (1 or 0). */ +class RoleInUseCoreStub +{ + /** @param string[] $permissions */ + public function __construct(public array $permissions) + { + } + + public function hasPermission($key): int + { + return in_array($key, $this->permissions, true) ? 1 : 0; + } +} + /** Minimal stand-in manager theme: alertAndQuit aborts, as the real one does. */ class RoleInUseThemeStub { public ?string $quitMessage = null; + public function __construct(public RoleInUseCoreStub $core) + { + } + + public function getCore(): RoleInUseCoreStub + { + return $this->core; + } + public function alertAndQuit($message, $lexicon = true, $url = ''): void { $this->quitMessage = (string) $message; @@ -31,10 +54,23 @@ public function alertAndQuit($message, $lexicon = true, $url = ''): void } } +/** + * A non-admin manager (role 2) by default, holding delete_role and every permission of role 5, + * so the delete_role check and the reach guard pass and only the in-use rule decides. + * + * @param string[] $permissions + */ +function roleInUseTheme(array $permissions = ['delete_role', 'edit_document'], int $actorRole = 2): RoleInUseThemeStub +{ + $_SESSION['mgrRole'] = $actorRole; + + return new RoleInUseThemeStub(new RoleInUseCoreStub($permissions)); +} + function roleInUseController(RoleInUseThemeStub $theme): UserRoleController { - // The delete branch only needs $_REQUEST (getElementId) and $this->managerTheme, - // so the heavy controller constructor can be bypassed. + // The delete branch only needs $_REQUEST (getElementId), $_SESSION['mgrRole'] and + // $this->managerTheme, so the heavy controller constructor can be bypassed. $controller = (new ReflectionClass(UserRoleController::class))->newInstanceWithoutConstructor(); $property = new ReflectionProperty(UserRoleController::class, 'managerTheme'); $property->setAccessible(true); @@ -43,6 +79,18 @@ function roleInUseController(RoleInUseThemeStub $theme): UserRoleController return $controller; } +function roleInUseRequestDelete(int $id): void +{ + $_GET['action'] = $_REQUEST['action'] = 'delete'; + $_GET['id'] = $_REQUEST['id'] = $id; +} + +function roleFiveSurvives($capsule): bool +{ + return $capsule->table('user_roles')->where('id', 5)->exists() + && $capsule->table('role_permissions')->where('role_id', 5)->exists(); +} + beforeEach(function () { $this->capsule = CacheRefreshHarness::boot(':memory:'); $schema = $this->capsule->getConnection()->getSchemaBuilder(); @@ -62,6 +110,24 @@ function roleInUseController(RoleInUseThemeStub $theme): UserRoleController $t->unsignedInteger('internalKey')->default(0); $t->unsignedInteger('role')->default(0); }); + // the permission keys the reach guard checks the actor against + $schema->create('permissions', function (Blueprint $t) { + $t->increments('id'); + $t->string('key')->default(''); + $t->unsignedTinyInteger('disabled')->default(0); + }); + // touched on the success path + $schema->create('site_module_roles', function (Blueprint $t) { + $t->increments('id'); + $t->unsignedInteger('role')->default(0); + }); + $schema->create('user_role_vars', function (Blueprint $t) { + $t->increments('id'); + $t->unsignedInteger('roleid')->default(0); + }); + foreach (['delete_role', 'edit_document', 'settings'] as $key) { + $this->capsule->table('permissions')->insert(['key' => $key]); + } $this->capsule->table('user_roles')->insert(['id' => 5, 'name' => 'Content team', 'description' => '']); $this->capsule->table('role_permissions')->insert(['role_id' => 5, 'permission' => 'edit_document']); @@ -72,45 +138,32 @@ function roleInUseController(RoleInUseThemeStub $theme): UserRoleController foreach (['action', 'id'] as $k) { unset($_GET[$k], $_REQUEST[$k]); } + unset($_SESSION['mgrRole']); }); it('refuses to delete a role while a user still holds it, and keeps the role and its permissions', function () { // one manager account assigned to role 5 $this->capsule->table('user_attributes')->insert(['internalKey' => 1, 'role' => 5]); + roleInUseRequestDelete(5); - $_GET['action'] = $_REQUEST['action'] = 'delete'; - $_GET['id'] = $_REQUEST['id'] = 5; - - $theme = new RoleInUseThemeStub(); + $theme = roleInUseTheme(); $controller = roleInUseController($theme); expect(fn () => $controller->process())->toThrow(RuntimeException::class, 'alertAndQuit:role_in_use_message'); // the refusal uses the lexicon key, and nothing was deleted expect($theme->quitMessage)->toBe('role_in_use_message') - ->and($this->capsule->table('user_roles')->where('id', 5)->exists())->toBeTrue() - ->and($this->capsule->table('role_permissions')->where('role_id', 5)->exists())->toBeTrue(); + ->and(roleFiveSurvives($this->capsule))->toBeTrue(); }); it('allows deleting a role that no user holds', function () { // role 5 exists but nobody is assigned to it - $_GET['action'] = $_REQUEST['action'] = 'delete'; - $_GET['id'] = $_REQUEST['id'] = 5; + roleInUseRequestDelete(5); - $theme = new RoleInUseThemeStub(); + $theme = roleInUseTheme(); $controller = roleInUseController($theme); - // site_module_roles / user_role_vars are touched on the success path too - $schema = $this->capsule->getConnection()->getSchemaBuilder(); - $schema->create('site_module_roles', function (Blueprint $t) { - $t->increments('id'); - $t->unsignedInteger('role')->default(0); - }); - $schema->create('user_role_vars', function (Blueprint $t) { - $t->increments('id'); - $t->unsignedInteger('roleid')->default(0); - }); - + // header() after the delete only warns in the CLI @$controller->process(); expect($theme->quitMessage)->toBeNull() @@ -118,6 +171,27 @@ function roleInUseController(RoleInUseThemeStub $theme): UserRoleController ->and($this->capsule->table('role_permissions')->where('role_id', 5)->exists())->toBeFalse(); }); +it('refuses the delete without delete_role before looking at who holds the role', function () { + $this->capsule->table('user_attributes')->insert(['internalKey' => 1, 'role' => 5]); + roleInUseRequestDelete(5); + + $theme = roleInUseTheme(['edit_document']); + + expect(fn () => roleInUseController($theme)->process())->toThrow(RuntimeException::class, 'alertAndQuit:error_no_privileges'); + expect(roleFiveSurvives($this->capsule))->toBeTrue(); +}); + +it('refuses to delete an unused role beyond the actor reach', function () { + // role 5 carries a permission the actor does not hold + $this->capsule->table('role_permissions')->insert(['role_id' => 5, 'permission' => 'settings']); + roleInUseRequestDelete(5); + + $theme = roleInUseTheme(); + + expect(fn () => roleInUseController($theme)->process())->toThrow(RuntimeException::class, 'alertAndQuit:error_no_privileges'); + expect(roleFiveSurvives($this->capsule))->toBeTrue(); +}); + test('the delete branch refuses an in-use role before any delete, via the lexicon', function () { $source = (string) file_get_contents(dirname(__DIR__, 3) . '/src/Controllers/UserRoles/UserRole.php'); @@ -125,6 +199,9 @@ function roleInUseController(RoleInUseThemeStub $theme): UserRoleController $check = strpos($source, "Models\\UserAttribute::where('role', \$id)->exists()", $delete); $quit = strpos($source, "alertAndQuit('role_in_use_message')", $delete); $roleDelete = strpos($source, "Models\\UserRole::query()->where('id', \$id)->delete()", $delete); + $permission = strpos($source, "hasPermission('delete_role')", $delete); + $reach = strpos($source, '$this->guardRoleReach($id);', $delete); + $firstDelete = strpos($source, '->delete();', $delete); expect($delete)->not->toBeFalse() ->and($check)->not->toBeFalse() @@ -132,7 +209,13 @@ function roleInUseController(RoleInUseThemeStub $theme): UserRoleController ->and($roleDelete)->not->toBeFalse() // the in-use refusal sits before the role is removed ->and($check)->toBeLessThan($roleDelete) - ->and($quit)->toBeLessThan($roleDelete); + ->and($quit)->toBeLessThan($roleDelete) + // delete_role and the reach guard come first, and every check precedes every delete + ->and($permission)->not->toBeFalse() + ->and($reach)->not->toBeFalse() + ->and($permission)->toBeLessThan($reach) + ->and($reach)->toBeLessThan($check) + ->and($quit)->toBeLessThan($firstDelete); }); test('the refusal message is defined in the English lexicon', function () { From e2b32fc3d58882e7c4571921642695dbcd400b3f Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 14:02:35 +0100 Subject: [PATCH 42/50] fix(cache): treat an ended publication window as complete in the schedule sync A document with pub_date and unpub_date both past matched the pending-publish rule of the front-end sync and of the refresh threshold, so every sync published it and then unpublished it again, and the threshold stayed due: the writes, events and page cache drops repeated on every request. Both now use SiteContent::publishDocuments() / unPublishDocuments(), the scopes Refresh site already applies, so such a window ends unpublished without the flip and leaves the threshold alone. Co-Authored-By: Claude Opus 5.5 --- core/src/Core.php | 15 ++++--- core/src/Legacy/Cache.php | 17 +++---- .../Legacy/PublishScheduleOverdueSyncTest.php | 44 +++++++++++++++++-- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/core/src/Core.php b/core/src/Core.php index 8413bc9c8a..8712def8d4 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -1321,21 +1321,22 @@ public function updatePubStatus() */ protected function runPubStatusSync($timeNow) { - // documents that need publishing - $where = "pub_date <= {$timeNow} AND pub_date != 0 AND published = 0"; - $resultPub = \EvolutionCMS\Models\SiteContent::select('id')->whereRaw($where)->get(); + // documents that need publishing; a window whose unpub_date has passed too is over + // (the same scopes as Refresh site), so it is not published only to be unpublished + $resultPub = \EvolutionCMS\Models\SiteContent::publishDocuments($timeNow)->select('id')->get(); if ($resultPub->isNotEmpty()) { - \EvolutionCMS\Models\SiteContent::whereRaw($where)->update(['published' => 1, 'publishedon' => $timeNow]); + \EvolutionCMS\Models\SiteContent::whereIn('id', $resultPub->pluck('id')) + ->update(['published' => 1, 'publishedon' => $timeNow]); foreach ($resultPub as $rowPub) { $this->invokeEvent('OnDocPublished', ['docid' => $rowPub->id]); } } // documents that need un-publishing - $where = "unpub_date <= {$timeNow} AND unpub_date != 0 AND published = 1"; - $resultUnpub = \EvolutionCMS\Models\SiteContent::select('id')->whereRaw($where)->get(); + $resultUnpub = \EvolutionCMS\Models\SiteContent::unPublishDocuments($timeNow)->select('id')->get(); if ($resultUnpub->isNotEmpty()) { - \EvolutionCMS\Models\SiteContent::whereRaw($where)->update(['published' => 0, 'publishedon' => 0]); + \EvolutionCMS\Models\SiteContent::whereIn('id', $resultUnpub->pluck('id')) + ->update(['published' => 0, 'publishedon' => 0]); foreach ($resultUnpub as $rowUnpub) { // drop only this page's cache instead of wiping the whole site $this->clearCache((string) $rowUnpub->id); diff --git a/core/src/Legacy/Cache.php b/core/src/Legacy/Cache.php index c3933ee14c..4fe9636078 100644 --- a/core/src/Legacy/Cache.php +++ b/core/src/Legacy/Cache.php @@ -285,19 +285,12 @@ public function getCacheRefreshTime() // still unsynced (e.g. the cache was rebuilt without applying it) must keep triggering // a front-end sync. The two queries above only look ahead, so without this an overdue // transition would drop out of the threshold and Core::updatePubStatus() would never - // run the sync. Pin the threshold to now instead; the predicate mirrors - // Core::runPubStatusSync() exactly, so it pins only while that sync still has work to do. + // run the sync. Pin the threshold to now instead. Core::runPubStatusSync() applies the + // same scopes, so this pins only while that sync still has work to do: a publication + // window that has already ended (both dates past, not published) is complete. $overduePending = Models\SiteContent::query() - ->where(function ($query) { - $query->where('published', 0) - ->where('pub_date', '!=', 0) - ->where('pub_date', '<=', $this->request_time); - }) - ->orWhere(function ($query) { - $query->where('published', 1) - ->where('unpub_date', '!=', 0) - ->where('unpub_date', '<=', $this->request_time); - }) + ->where(fn ($query) => $query->publishDocuments($this->request_time)) + ->orWhere(fn ($query) => $query->unPublishDocuments($this->request_time)) ->exists(); if ($overduePending) { diff --git a/core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php b/core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php index 8356203c0a..8f6afacea6 100644 --- a/core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php +++ b/core/tests/Unit/Legacy/PublishScheduleOverdueSyncTest.php @@ -24,9 +24,13 @@ | fallback relied on in the PR did not actually fire. | | getCacheRefreshTime() now also detects an overdue-but-unsynced transition - with -| the exact predicate Core::runPubStatusSync() applies - and pins the threshold to -| "now" so the next front-end request runs the sync. These tests drive the real -| threshold calculation and the real Core::runPubStatusSync() end to end. +| the scopes Core::runPubStatusSync() applies (SiteContent::publishDocuments() / +| unPublishDocuments(), as Refresh site does) - and pins the threshold to "now" so +| the next front-end request runs the sync. A publication window whose unpub_date +| has passed as well is over: it is neither published only to be unpublished again +| nor kept due, which would repeat those writes and events on every request. These +| tests drive the real threshold calculation and the real Core::runPubStatusSync() +| end to end. | */ @@ -196,3 +200,37 @@ function overdueThreshold(string $dir): int expect(overdueThreshold($this->dir))->toBe(0); }); + +it('treats a publication window that has already ended as complete', function (int $published, int $pubOffset, int $unpubOffset) { + $now = $this->now; + overdueMakeDoc([ + 'id' => 1, 'alias' => 'ended', 'published' => $published, + 'pub_date' => $now + $pubOffset, 'unpub_date' => $now + $unpubOffset, + ]); + + $this->sync->publishTimeConfig(); + // only a document still marked published has work left (the unpublish) + expect(overdueThreshold($this->dir))->toBe($published === 1 ? $now : 0); + + $core = new OverduePubSyncCore(); + $core->cachePath = $this->dir; + $core->syncNow($now); + + // it ends unpublished; a published document is unpublished once, never re-published + expect((int) SiteContent::find(1)->published)->toBe(0) + ->and($core->firedEvents)->toBe($published === 1 ? [['OnDocUnPublished', 1]] : []) + ->and(overdueThreshold($this->dir))->toBe(0); + + // a repeated sync (the next front-end request) writes and fires nothing + $core->firedEvents = []; + $core->clearedCaches = []; + $core->syncNow($now + 1); + + expect((int) SiteContent::find(1)->published)->toBe(0) + ->and($core->firedEvents)->toBe([]) + ->and($core->clearedCaches)->toBe([]); +})->with([ + 'never published, unpub after pub' => [0, -200, -100], + 'never published, unpub before pub' => [0, -100, -200], + 'still published, both dates past' => [1, -200, -100], +]); From 8aa757355db2c6fb2ccc67355a8a480ae7ceca0e Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 14:18:02 +0100 Subject: [PATCH 43/50] fix(manager): validate the file manager root by the file manager's own path rule Core judged a manager's own filemanager_path by realpath(), but the file managers name paths by fileManagerCanonicalPath(), which refuses spellings realpath() accepts (a ".." back over the root, a link that names a folder a second time). Both file managers then replaced the refusal with the site folder, wider than a narrowed site-wide root. fileManagerRoot() validates the override with the consumer's rule and is the value the classic manager, its ACL keys and the media browser's top-level rule use; it falls back only to the site-wide root, and refuses when that does not resolve. Co-Authored-By: Claude Opus 5.5 --- core/functions/actions/files.php | 29 ++++- .../tests/Fixtures/file-manager-user-root.php | 81 ++++++++++++++ .../Fixtures/shared-type-root-request.php | 2 +- .../UserFileManagerPathContainmentTest.php | 105 ++++++++++++++++++ manager/actions/files.dynamic.php | 7 +- manager/media/browser/mcpuk/core/browser.php | 14 ++- 6 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 core/tests/Fixtures/file-manager-user-root.php diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php index cacf094b17..b6d4764bee 100644 --- a/core/functions/actions/files.php +++ b/core/functions/actions/files.php @@ -104,6 +104,31 @@ function fileManagerDenied($reason = '', $path = null) } } +if(!function_exists('fileManagerRoot')) { + /** + * The folder the current manager's file manager works in. A manager's own filemanager_path + * (a user setting) may narrow the site-wide root, never widen it: it is used only when + * fileManagerCanonicalPath(), the rule every path below the root is held to, names it inside + * the site-wide root. Otherwise the site-wide root is used, and when that does not resolve + * either the answer is null: callers refuse rather than fall back to the site folder. + * + * @return string|null forward slashes, no trailing slash + */ + function fileManagerRoot() + { + $evo = evolutionCMS(); + $system = $evo->configGlobal['filemanager_path'] ?? $evo->getConfig('filemanager_path'); + $system = realpath(str_replace('[(base_path)]', EVO_BASE_PATH, (string) $system)); + if ($system === false) { + return null; + } + $system = rtrim(str_replace('\\', '/', $system), '/'); + $own = fileManagerCanonicalPath($evo->getConfig('filemanager_path')); + + return $own !== false && FileManagerAccess::isWithin($system, $own) ? $own : $system; + } +} + if(!function_exists('fileManagerAclKey')) { /** * The file_groups key of a path given relative to the current manager's file manager root. @@ -117,8 +142,8 @@ function fileManagerAclKey($relativePath) { static $roots = null; if ($roots === null) { - $userRoot = fileManagerCanonicalPath(evolutionCMS()->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH); - $roots = [FileManagerAccess::aclRoot(), rtrim(str_replace('\\', '/', $userRoot), '/')]; + $aclRoot = FileManagerAccess::aclRoot(); + $roots = [$aclRoot, fileManagerRoot() ?? $aclRoot]; } return FileManagerAccess::aclKey($roots[0], $roots[1], $relativePath); diff --git a/core/tests/Fixtures/file-manager-user-root.php b/core/tests/Fixtures/file-manager-user-root.php new file mode 100644 index 0000000000..7c1e4ee191 --- /dev/null +++ b/core/tests/Fixtures/file-manager-user-root.php @@ -0,0 +1,81 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); +$capsule->getConnection()->statement('CREATE TABLE user_settings (user INTEGER, setting_name TEXT, setting_value TEXT)'); +if (isset($options['own'])) { + $capsule->getConnection()->table('user_settings')->insert([ + 'user' => 1, 'setting_name' => 'filemanager_path', 'setting_value' => $options['own'], + ]); +} + +$core = evo(); +// getConfig() lets config/cms/settings override a setting; there are none here +$core->instance('config', new Illuminate\Config\Repository([])); +$core->config =['filemanager_path' => $options['system'], 'rb_base_dir' => '[(base_path)]assets/']; +$_SESSION = []; +$core->getUserSettings(); + +echo json_encode(['root' => fileManagerRoot(), 'config' => $core->getConfig('filemanager_path')]); diff --git a/core/tests/Fixtures/shared-type-root-request.php b/core/tests/Fixtures/shared-type-root-request.php index 16a56510a8..9dcc8b5ba2 100644 --- a/core/tests/Fixtures/shared-type-root-request.php +++ b/core/tests/Fixtures/shared-type-root-request.php @@ -241,7 +241,7 @@ function evolutionCMS() ]; // the classic file manager, as files.dynamic.php sets it up - $root = rtrim(str_replace('\\', '/', fileManagerCanonicalPath(evo()->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH)), '/'); + $root = fileManagerRoot(); $relative = static fn ($request) => fileManagerResolvePath($root, $request)['relative'] ?? null; $resolved = fileManagerResolvePath($root, 'assets/images/private/secret.png'); $output += [ diff --git a/core/tests/Unit/Security/UserFileManagerPathContainmentTest.php b/core/tests/Unit/Security/UserFileManagerPathContainmentTest.php index c299b62281..c10dadc134 100644 --- a/core/tests/Unit/Security/UserFileManagerPathContainmentTest.php +++ b/core/tests/Unit/Security/UserFileManagerPathContainmentTest.php @@ -46,3 +46,108 @@ ->and($body)->toContain("configGlobal['filemanager_path']") ->and($body)->toContain("\$this->setConfig('filemanager_path', \$systemPath);"); }); + +/* +| The value the manager actually uses. Core::getUserSettings() judges the override by realpath(), +| but the file managers name paths by fileManagerCanonicalPath(), which refuses spellings +| realpath() accepts (a ".." back over the root, a link that gives a folder a second name). Both +| file managers used to replace such a refusal with the site folder, wider than a narrowed +| site-wide root. fileManagerRoot() validates the override by the consumer's own rule and falls +| back only to the site-wide root, or to nothing when that root does not resolve. +*/ + +function userRootSite(): string +{ + $site = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-user-root-' . bin2hex(random_bytes(6)); + foreach (['assets/files/sub', 'assets/files/team', 'outside'] as $dir) { + mkdir($site . '/' . $dir, 0777, true); + } + + return rtrim(str_replace('\\', '/', realpath($site)), '/'); +} + +function userRootRemove(string $path): void +{ + if (is_link($path) || is_file($path)) { + @unlink($path) || @rmdir($path); + return; + } + if (!is_dir($path)) { + return; + } + foreach (array_diff(scandir($path), ['.', '..']) as $entry) { + userRootRemove($path . '/' . $entry); + } + @rmdir($path); +} + +function userRootFor(string $site, string $system, ?string $own): array +{ + $options = ['system' => $system] + ($own === null ? [] : ['own' => $own]); + $output = evoRunPhp(dirname(__DIR__, 2) . '/Fixtures/file-manager-user-root.php', [$site, base64_encode(json_encode($options))]); + $decoded = json_decode($output, true); + expect($decoded)->toBeArray($output); + + return $decoded; +} + +beforeEach(function () { + $this->site = userRootSite(); + $this->system = $this->site . '/assets/files'; +}); + +afterEach(function () { + userRootRemove($this->site); +}); + +it('keeps the file manager root within a narrowed site-wide root, whatever the manager sets', function (?string $own, string $expected) { + $own = $own === null ? null : str_replace('{site}', $this->site, $own); + $result = userRootFor($this->site, '[(base_path)]assets/files/', $own); + + expect($result['root'])->toBe($this->site . '/' . $expected) + ->and(FileManagerAccess::isWithin($this->system, $result['root']))->toBeTrue(); +})->with([ + 'no own root' => [null, 'assets/files'], + 'a subfolder' => ['{site}/assets/files/sub', 'assets/files/sub'], + 'the site folder' => ['{site}/', 'assets/files'], + 'a folder outside the site' => ['{site}/outside', 'assets/files'], + 'a sibling sharing the prefix' => ['{site}/assets/files-private', 'assets/files'], + // realpath() lands on assets/files/sub, so Core keeps it; the file manager refuses the + // spelling, and the root used to become the whole site + 'contained by realpath, refused by the rule' => ['{site}/assets/files/sub/../../files/sub', 'assets/files'], +]); + +it('does not widen to the site folder through a link that names a folder a second time', function () { + if (!@symlink($this->system . '/team', $this->system . '/alias')) { + $this->markTestSkipped('symlinks are not available'); + } + + $result = userRootFor($this->site, '[(base_path)]assets/files/', $this->system . '/alias'); + + // Core accepted it: realpath() of the link is assets/files/team + expect($result['config'])->toBe($this->system . '/alias') + // the file manager does not pass such a link, and stays on the site-wide root + ->and($result['root'])->toBe($this->system); +}); + +it('refuses rather than falling back to the site folder when the site-wide root does not resolve', function () { + $result = userRootFor($this->site, '[(base_path)]missing/', $this->site . '/assets/files/sub'); + + expect($result['root'])->toBeNull(); +}); + +it('takes the root of both file managers from fileManagerRoot(), never the site folder', function () { + $root = dirname(__DIR__, 4); + $dynamic = (string) file_get_contents($root . '/manager/actions/files.dynamic.php'); + $browser = (string) file_get_contents($root . '/manager/media/browser/mcpuk/core/browser.php'); + $functions = (string) file_get_contents($root . '/core/functions/actions/files.php'); + $aclKey = substr($functions, strpos($functions, 'function fileManagerAclKey'), 800); + + expect($dynamic)->toContain('$filemanager_path = fileManagerRoot();') + ->and($dynamic)->toContain('if ($filemanager_path === null) {') + ->and($dynamic)->not->toContain("getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH)") + ->and($aclKey)->toContain('fileManagerRoot()') + ->and($aclKey)->not->toContain('realpath(EVO_BASE_PATH)') + ->and(substr_count($browser, '$root = fileManagerRoot();'))->toBe(2) + ->and($browser)->not->toContain("realpath(\$this->modx->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH)"); +}); diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php index 042459690f..4b3541673b 100755 --- a/manager/actions/files.dynamic.php +++ b/manager/actions/files.dynamic.php @@ -39,8 +39,11 @@ $uploadablefiles = array_merge($upload_files, $upload_images, $upload_media); $uploadablefiles = add_dot($uploadablefiles); $upload_maxsize = evo()->getConfig('upload_maxsize'); -$filemanager_path = rtrim(str_replace('\\', '/', - fileManagerCanonicalPath(evo()->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH)), '/'); +$filemanager_path = fileManagerRoot(); +if ($filemanager_path === null) { + fileManagerLogDenied(null, null, 'the file manager root does not resolve'); + evo()->webAlertAndQuit($_lang["files_access_denied"]); +} $base_path = rtrim(str_replace('\\', '/', realpath(EVO_BASE_PATH)), '/'); // end settings // get the current work directory diff --git a/manager/media/browser/mcpuk/core/browser.php b/manager/media/browser/mcpuk/core/browser.php index aea26eb1cd..057e7d1d34 100755 --- a/manager/media/browser/mcpuk/core/browser.php +++ b/manager/media/browser/mcpuk/core/browser.php @@ -1576,7 +1576,13 @@ protected function canModifyLink($absPath) if (!fileManagerAclApplies()) { return true; } - $root = rtrim(str_replace('\\', '/', (string) (realpath($this->modx->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH))), '/'); + // the classic manager's root, validated there, as the real path this method compares with + $root = fileManagerRoot(); + $root = $root === null ? false : realpath($root); + if ($root === false) { + return false; + } + $root = rtrim(str_replace('\\', '/', $root), '/'); if (!\EvolutionCMS\Support\FileManagerAccess::isWithin($root, $lexical) || $lexical === $root) { return false; } @@ -2188,7 +2194,11 @@ protected function canModifyExisting($absPath) if (!fileManagerAclApplies()) { return true; } - $root = realpath($this->modx->getConfig('filemanager_path')) ?: realpath(EVO_BASE_PATH); + $root = fileManagerRoot(); + $root = $root === null ? false : realpath($root); + if ($root === false) { + return false; + } $relative = \EvolutionCMS\Support\FileManagerAccess::getRelativePath($root, $absPath); if ($relative === '') { // not under the file manager's root (or the root itself): nothing to compare it with From 806a121e75913c9dbfa3f713b3c3f627ce3dc170 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 14:27:30 +0100 Subject: [PATCH 44/50] fix(manager): keep the file checksum with the Security tab in save_settings sys_files_checksum was computed from check_files_onlogin and stored before the security_settings check, and it is not a key the tab renders, so a manager without the permission still rewrote it, and a save with the tab hidden read a missing field. It is now computed only for a manager with the permission (from the posted list, else the stored one) and dropped from the request otherwise. Co-Authored-By: Claude Opus 5.5 --- .../tests/Fixtures/save-settings-security.php | 110 ++++++++++++++++++ .../SecuritySettingsPermissionTest.php | 92 +++++++++++++++ .../processors/save_settings.processor.php | 10 +- 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 core/tests/Fixtures/save-settings-security.php diff --git a/core/tests/Fixtures/save-settings-security.php b/core/tests/Fixtures/save-settings-security.php new file mode 100644 index 0000000000..54902c54fe --- /dev/null +++ b/core/tests/Fixtures/save-settings-security.php @@ -0,0 +1,110 @@ + value, notices: PHP warnings/notices raised} + */ + +[, $site, $payload] = $argv; +$options = json_decode(base64_decode($payload), true); + +define('EVO_BASE_PATH', $site . '/'); +define('EVO_BASE_URL', '/'); +define('EVO_MANAGER_PATH', $site . '/manager/'); +define('EVO_CORE_PATH', $site . '/core/'); +define('IN_MANAGER_MODE', true); +define('IN_INSTALL_MODE', false); +define('EVO_API_MODE', false); + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +$capsule = new Illuminate\Database\Capsule\Manager(); +$capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); +$capsule->getConnection()->statement('CREATE TABLE system_settings (setting_name TEXT PRIMARY KEY, setting_value TEXT)'); +$capsule->getConnection()->table('system_settings')->insert([ + ['setting_name' => 'site_name', 'setting_value' => 'Old name'], + ['setting_name' => 'use_udperms', 'setting_value' => '1'], + ['setting_name' => 'allow_eval', 'setting_value' => 'with_scan'], + ['setting_name' => 'check_files_onlogin', 'setting_value' => "index.php"], + ['setting_name' => 'sys_files_checksum', 'setting_value' => 'stored-checksum'], +]); + +final class SaveSettingsStubEvo +{ + public array $config; + + public function __construct(private array $permissions) + { + $this->config = Illuminate\Database\Capsule\Manager::table('system_settings') + ->pluck('setting_value', 'setting_name')->all() + ['manager_theme' => 'default']; + } + + public function hasPermission($key) + { + return in_array($key, $this->permissions, true) ? 1 : 0; + } + + public function getConfig($key, $default = null) + { + return $this->config[$key] ?? $default; + } + + public function getManagerApi() + { + // the real checksum, without the manager API's bootstrap + return (new ReflectionClass(EvolutionCMS\Legacy\ManagerApi::class))->newInstanceWithoutConstructor(); + } + + public function getVersionData($key = null) + { + return '3.5.9'; + } + + public function logEvent(...$args) + { + } + + public function clearCache(...$args) + { + } + + public function webAlertAndQuit($message, $url = '') + { + echo json_encode(['quit' => $message]); + exit; + } +} + +function config($key = null, $default = null) +{ + return $key === 'cms.settings' ? [] : $default; +} + +$notices = []; +set_error_handler(static function ($severity, $message, $file, $line) use (&$notices) { + $notices[] = $message . ' @ ' . basename($file) . ':' . $line; + + return true; +}); + +$modx = new SaveSettingsStubEvo($options['permissions']); +$_lang = ['error_no_privileges' => 'error_no_privileges']; +$_SERVER['SERVER_SOFTWARE'] = 'Apache'; +$_POST = $options['post'] + [ + 'friendly_urls' => '0', + 'filemanager_path' => '[(base_path)]', + 'rb_base_dir' => '[(base_path)]assets/', + 'stay' => '', +]; + +include dirname(__DIR__, 3) . '/manager/processors/save_settings.processor.php'; + +echo json_encode([ + 'settings' => Illuminate\Database\Capsule\Manager::table('system_settings')->pluck('setting_value', 'setting_name')->all(), + 'notices' => $notices, +]); diff --git a/core/tests/Unit/Security/SecuritySettingsPermissionTest.php b/core/tests/Unit/Security/SecuritySettingsPermissionTest.php index 9458c868e8..7de4f1bb3f 100644 --- a/core/tests/Unit/Security/SecuritySettingsPermissionTest.php +++ b/core/tests/Unit/Security/SecuritySettingsPermissionTest.php @@ -77,3 +77,95 @@ function securityTabKeys(): array expect($matches[1])->toBe(['1']); }); + +/* +| save_settings, run for real: sys_files_checksum is derived from check_files_onlogin, so it is +| computed and stored only for a manager who may edit that list. Without the permission the +| hidden tab sends neither key, and a crafted request that does changes nothing. +*/ + +function saveSettingsRun(string $site, array $permissions, array $post): array +{ + $output = evoRunPhp(dirname(__DIR__, 2) . '/Fixtures/save-settings-security.php', [ + $site, base64_encode(json_encode(['permissions' => $permissions, 'post' => $post])), + ]); + $decoded = json_decode($output, true); + expect($decoded)->toBeArray($output); + + return $decoded; +} + +function saveSettingsChecksum(string $site, array $files): string +{ + $sums = []; + foreach ($files as $file) { + $sums[$site . '/' . $file] = md5_file($site . '/' . $file); + } + + return serialize($sums); +} + +beforeEach(function () { + $this->site = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-save-settings-' . bin2hex(random_bytes(6)); + mkdir($this->site . '/manager', 0777, true); + file_put_contents($this->site . '/index.php', 'site . '/manager/index.php', 'site . '/manager/index.php'); + @unlink($this->site . '/index.php'); + @rmdir($this->site . '/manager'); + @rmdir($this->site); +}); + +it('saves an ordinary form with the Security tab hidden and leaves the security settings alone', function () { + $result = saveSettingsRun($this->site, ['settings'], ['site_name' => 'New name']); + + expect($result['notices'])->toBe([]) + ->and($result['settings']['site_name'])->toBe('New name') + ->and($result['settings']['check_files_onlogin'])->toBe('index.php') + ->and($result['settings']['sys_files_checksum'])->toBe('stored-checksum') + ->and($result['settings']['use_udperms'])->toBe('1'); +}); + +it('ignores security fields and the checksum in a crafted request without the permission', function () { + $result = saveSettingsRun($this->site, ['settings'], [ + 'site_name' => 'New name', + 'use_udperms' => '0', + 'allow_eval' => 'everywhere', + 'check_files_onlogin' => '', + 'sys_files_checksum' => 'forged', + ]); + + expect($result['notices'])->toBe([]) + ->and($result['settings']['site_name'])->toBe('New name') + ->and($result['settings']['use_udperms'])->toBe('1') + ->and($result['settings']['allow_eval'])->toBe('with_scan') + ->and($result['settings']['check_files_onlogin'])->toBe('index.php') + ->and($result['settings']['sys_files_checksum'])->toBe('stored-checksum'); +}); + +it('saves the security settings and recomputes the checksum for a manager with the permission', function () { + $result = saveSettingsRun($this->site, ['settings', 'security_settings'], [ + 'site_name' => 'New name', + 'use_udperms' => '0', + 'check_files_onlogin' => "index.php\nmanager/index.php", + // the stored checksum is always the one computed from the list + 'sys_files_checksum' => 'forged', + ]); + + expect($result['notices'])->toBe([]) + ->and($result['settings']['use_udperms'])->toBe('0') + ->and($result['settings']['check_files_onlogin'])->toBe("index.php\nmanager/index.php") + ->and($result['settings']['sys_files_checksum']) + ->toBe(saveSettingsChecksum($this->site, ['index.php', 'manager/index.php'])); +}); + +it('computes the checksum from the stored list when a permitted save does not carry it', function () { + $result = saveSettingsRun($this->site, ['settings', 'security_settings'], ['site_name' => 'New name']); + + expect($result['notices'])->toBe([]) + ->and($result['settings']['check_files_onlogin'])->toBe('index.php') + ->and($result['settings']['sys_files_checksum'])->toBe(saveSettingsChecksum($this->site, ['index.php'])); +}); diff --git a/manager/processors/save_settings.processor.php b/manager/processors/save_settings.processor.php index 1748788227..53f626e4ee 100755 --- a/manager/processors/save_settings.processor.php +++ b/manager/processors/save_settings.processor.php @@ -53,8 +53,16 @@ $data['lang_code'] = $data['manager_language']; } } - $data['sys_files_checksum'] = $modx->getManagerApi()->getSystemChecksum($data['check_files_onlogin']); $mayEditSecurity = $modx->hasPermission('security_settings'); + if ($mayEditSecurity) { + $data['sys_files_checksum'] = $modx->getManagerApi()->getSystemChecksum( + (string) ($data['check_files_onlogin'] ?? $modx->getConfig('check_files_onlogin', '')) + ); + } else { + // The checksum belongs to check_files_onlogin on the Security tab: without the permission + // both stay as stored, whatever the request carries. + unset($data['sys_files_checksum']); + } foreach ($data as $k => $v) { if (isset($defaultSettings[$k])) { continue; From 110d61b4c1bdae9235b448779ded4a25716060ae Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 17:25:55 +0100 Subject: [PATCH 45/50] fix(manager): store each system setting under one spelling in save_settings MySQL and MariaDB match setting_name by collation (utf8mb4_general_ci by default), so a crafted USE_UDPERMS or Allow_Eval passed the case-sensitive security_settings check and then updated the use_udperms / allow_eval row. A posted name is now stored only when it is plain ASCII and, if a stored, factory, gated or derived setting matches it case-insensitively, exactly that spelling; isGated() ignores case, and every spelling of the derived sys_files_checksum is dropped from the request. The save_settings tests run the real processor on SQLite with NOCASE and, when EVO_TEST_MYSQL_HOST is set, on MySQL/MariaDB with utf8mb4_general_ci and utf8mb4_unicode_ci. Also: SecuritySettings was missing from the committed classmap, and the install stub seeders now match the core ones (security_settings for the administrator, no dead action_ok grants), which PublisherDefaultPresetTest checks. Co-Authored-By: Claude Opus 5.5 --- core/src/Support/SecuritySettings.php | 5 +- core/src/Support/SystemSettingName.php | 37 +++++ .../tests/Fixtures/save-settings-security.php | 47 +++++- .../Install/SeederPermissionGrantsTest.php | 34 ++-- .../SecuritySettingsPermissionTest.php | 151 ++++++++++++++---- core/vendor/composer/autoload_classmap.php | 2 + core/vendor/composer/autoload_static.php | 2 + .../install/UserPermissionsTableSeeder.php | 8 +- .../seeds/install/UserRolesTableSeeder.php | 4 +- .../processors/save_settings.processor.php | 23 ++- 10 files changed, 247 insertions(+), 66 deletions(-) create mode 100644 core/src/Support/SystemSettingName.php diff --git a/core/src/Support/SecuritySettings.php b/core/src/Support/SecuritySettings.php index 02eb5825f5..3816fae6d7 100644 --- a/core/src/Support/SecuritySettings.php +++ b/core/src/Support/SecuritySettings.php @@ -41,10 +41,11 @@ final class SecuritySettings ]; /** - * Whether $key is a security setting that needs the `security_settings` permission. + * Whether $key is a security setting that needs the `security_settings` permission. Letter + * case does not count: the database matches setting names case-insensitively. */ public static function isGated(string $key): bool { - return in_array($key, self::GATED, true); + return in_array(strtolower($key), self::GATED, true); } } diff --git a/core/src/Support/SystemSettingName.php b/core/src/Support/SystemSettingName.php new file mode 100644 index 0000000000..ce5a5c84dc --- /dev/null +++ b/core/src/Support/SystemSettingName.php @@ -0,0 +1,37 @@ + value, notices: PHP warnings/notices raised} + * argv: site root, base64 JSON {permissions: string[], post: array, mysql?: {host, port, database, + * username, password, collation}} + * Prints JSON: {settings: stored name => value, notices: PHP warnings/notices raised, + * folds: [spelling => whether a WHERE on it finds the use_udperms row]} */ [, $site, $payload] = $argv; @@ -22,17 +25,42 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; $capsule = new Illuminate\Database\Capsule\Manager(); -$capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); +$mysql = $options['mysql'] ?? null; +if (is_array($mysql)) { + $collation = (string) $mysql['collation']; + if (!preg_match('/^utf8mb4_[a-z0-9_]+$/', $collation)) { + exit('bad collation'); + } + $capsule->addConnection([ + 'driver' => 'mysql', 'host' => $mysql['host'], 'port' => $mysql['port'], 'database' => $mysql['database'], + 'username' => $mysql['username'], 'password' => $mysql['password'], + 'charset' => 'utf8mb4', 'collation' => $collation, 'prefix' => '', + ]); +} else { + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); +} $capsule->setAsGlobal(); $capsule->bootEloquent(); -$capsule->getConnection()->statement('CREATE TABLE system_settings (setting_name TEXT PRIMARY KEY, setting_value TEXT)'); -$capsule->getConnection()->table('system_settings')->insert([ +$db = $capsule->getConnection(); +if (is_array($mysql)) { + // the column as the schema migration creates it + $db->statement('DROP TABLE IF EXISTS system_settings'); + $db->statement("CREATE TABLE system_settings (setting_name VARCHAR(50) NOT NULL DEFAULT '' PRIMARY KEY, setting_value TEXT) DEFAULT CHARSET=utf8mb4 COLLATE={$collation}"); +} else { + $db->statement('CREATE TABLE system_settings (setting_name TEXT COLLATE NOCASE PRIMARY KEY, setting_value TEXT)'); +} +$db->table('system_settings')->insert([ ['setting_name' => 'site_name', 'setting_value' => 'Old name'], ['setting_name' => 'use_udperms', 'setting_value' => '1'], ['setting_name' => 'allow_eval', 'setting_value' => 'with_scan'], ['setting_name' => 'check_files_onlogin', 'setting_value' => "index.php"], ['setting_name' => 'sys_files_checksum', 'setting_value' => 'stored-checksum'], + ['setting_name' => 'jpegQuality', 'setting_value' => '85'], ]); +$folds = []; +foreach (['USE_UDPERMS', 'use_udpérms', 'use_udperms '] as $spelling) { + $folds[$spelling] = $db->table('system_settings')->where('setting_name', $spelling)->exists(); +} final class SaveSettingsStubEvo { @@ -41,7 +69,9 @@ final class SaveSettingsStubEvo public function __construct(private array $permissions) { $this->config = Illuminate\Database\Capsule\Manager::table('system_settings') - ->pluck('setting_value', 'setting_name')->all() + ['manager_theme' => 'default']; + ->pluck('setting_value', 'setting_name')->all() + // factory defaults the Core merges in under the stored values + + ['manager_theme' => 'default', 'settings_version' => '0']; } public function hasPermission($key) @@ -105,6 +135,7 @@ function config($key = null, $default = null) include dirname(__DIR__, 3) . '/manager/processors/save_settings.processor.php'; echo json_encode([ - 'settings' => Illuminate\Database\Capsule\Manager::table('system_settings')->pluck('setting_value', 'setting_name')->all(), + 'settings' => $db->table('system_settings')->pluck('setting_value', 'setting_name')->all(), 'notices' => $notices, + 'folds' => $folds, ]); diff --git a/core/tests/Unit/Install/SeederPermissionGrantsTest.php b/core/tests/Unit/Install/SeederPermissionGrantsTest.php index 07c0efcaf7..2057d294c5 100644 --- a/core/tests/Unit/Install/SeederPermissionGrantsTest.php +++ b/core/tests/Unit/Install/SeederPermissionGrantsTest.php @@ -14,17 +14,23 @@ | */ -function seederSource(string $file): string +// The install stub seeds a fresh install; the core seeders repair the baseline on every update. +const GRANT_SEEDER_DIRS = [ + 'core seeders' => 'core/database/seeders', + 'install stub' => 'install/stubs/seeds/install', +]; + +function seederSource(string $file, string $dir = 'core/database/seeders'): string { - return (string) file_get_contents(dirname(__DIR__, 3) . '/database/seeders/' . $file); + return (string) file_get_contents(dirname(__DIR__, 4) . '/' . $dir . '/' . $file); } /** @return string[] */ -function seededPermissionKeys(): array +function seededPermissionKeys(string $dir = 'core/database/seeders'): array { preg_match_all( "/'key'\\s*=>\\s*'([^']+)'/", - seederSource('UserPermissionsTableSeeder.php'), + seederSource('UserPermissionsTableSeeder.php', $dir), $matches ); @@ -32,25 +38,25 @@ function seededPermissionKeys(): array } /** @return string[] */ -function grantedPermissionKeys(): array +function grantedPermissionKeys(string $dir = 'core/database/seeders'): array { preg_match_all( "/'permission'\\s*=>\\s*'([^']+)'/", - seederSource('UserRolesTableSeeder.php'), + seederSource('UserRolesTableSeeder.php', $dir), $matches ); return array_values(array_unique($matches[1])); } -it('grants only permissions that are actually defined', function () { - $undefined = array_diff(grantedPermissionKeys(), seededPermissionKeys()); +it('grants only permissions that are actually defined', function (string $dir) { + $undefined = array_diff(grantedPermissionKeys($dir), seededPermissionKeys($dir)); expect($undefined)->toBe([]); -}); +})->with(GRANT_SEEDER_DIRS); -it('no longer defines or grants the dead action_ok permission', function () { - expect(seededPermissionKeys())->not->toContain('action_ok') - ->and(grantedPermissionKeys())->not->toContain('role_actionok') - ->and(grantedPermissionKeys())->not->toContain('action_ok'); -}); +it('no longer defines or grants the dead action_ok permission', function (string $dir) { + expect(seededPermissionKeys($dir))->not->toContain('action_ok') + ->and(grantedPermissionKeys($dir))->not->toContain('role_actionok') + ->and(grantedPermissionKeys($dir))->not->toContain('action_ok'); +})->with(GRANT_SEEDER_DIRS); diff --git a/core/tests/Unit/Security/SecuritySettingsPermissionTest.php b/core/tests/Unit/Security/SecuritySettingsPermissionTest.php index 7de4f1bb3f..a58bdb879f 100644 --- a/core/tests/Unit/Security/SecuritySettingsPermissionTest.php +++ b/core/tests/Unit/Security/SecuritySettingsPermissionTest.php @@ -1,6 +1,7 @@ and(SecuritySettings::isGated('allow_eval'))->toBeTrue() ->and(SecuritySettings::isGated('safe_functions_at_eval'))->toBeTrue() // an ordinary setting is not gated - ->and(SecuritySettings::isGated('site_name'))->toBeFalse(); + ->and(SecuritySettings::isGated('site_name'))->toBeFalse() + // the database finds the use_udperms row by these too + ->and(SecuritySettings::isGated('USE_UDPERMS'))->toBeTrue() + ->and(SecuritySettings::isGated('Allow_Eval'))->toBeTrue(); }); +test('only one spelling of a setting name is storable', function (string $name, bool $storable) { + $existing = ['site_name', 'jpegQuality', 'use_udperms']; + + expect(SystemSettingName::storable($name, $existing))->toBe($storable); +})->with([ + 'a stored name' => ['site_name', true], + 'a stored mixed-case name' => ['jpegQuality', true], + 'a new name' => ['my_setting', true], + 'another case of a stored name' => ['USE_UDPERMS', false], + 'another case of a mixed-case name' => ['jpegquality', false], + 'an accented spelling' => ['use_udpérms', false], + 'a trailing space' => ['use_udperms ', false], + 'a zero-width character' => ["use_udperms\u{200B}", false], + 'a dot' => ['session.cookie', false], + 'empty' => ['', false], +]); + it('skips the gated keys in save_settings without the permission', function () { $source = b11Source('manager/processors/save_settings.processor.php'); @@ -66,8 +87,11 @@ function securityTabKeys(): array ->and($source)->toContain("@include('manager::page.system_settings.security')"); }); -it('grants security_settings to the administrator role only', function () { - $source = b11Source('core/database/seeders/UserRolesTableSeeder.php'); +// the install stub seeds a fresh site, the core seeder repairs the baseline on every update +it('defines security_settings and grants it to the administrator role only', function (string $dir) { + $source = b11Source($dir . '/UserRolesTableSeeder.php'); + + expect(b11Source($dir . '/UserPermissionsTableSeeder.php'))->toMatch("/'key'\s*=>\s*'security_settings'/"); preg_match_all( "/'permission'\\s*=>\\s*'security_settings',\\s*'role_id'\\s*=>\\s*(\\d+)/", @@ -76,21 +100,54 @@ function securityTabKeys(): array ); expect($matches[1])->toBe(['1']); -}); +})->with(['core/database/seeders', 'install/stubs/seeds/install']); /* | save_settings, run for real: sys_files_checksum is derived from check_files_onlogin, so it is | computed and stored only for a manager who may edit that list. Without the permission the | hidden tab sends neither key, and a crafted request that does changes nothing. +| +| MySQL and MariaDB match setting_name by collation, so `USE_UDPERMS` or `use_udpérms` would +| update the `use_udperms` row while the permission check saw another name. Each case runs on +| SQLite with NOCASE and, when EVO_TEST_MYSQL_HOST is set, on MySQL/MariaDB with the installer's +| default utf8mb4_general_ci and with utf8mb4_unicode_ci (EVO_TEST_MYSQL_COLLATIONS overrides; +| EVO_TEST_MYSQL_PORT, _DATABASE - an existing database, default evo_test - _USER, _PASSWORD). */ -function saveSettingsRun(string $site, array $permissions, array $post): array +/** @return array */ +function saveSettingsBackends(): array +{ + $backends = ['sqlite NOCASE' => [null]]; + $host = getenv('EVO_TEST_MYSQL_HOST'); + if ($host === false || $host === '') { + return $backends; + } + $collations = getenv('EVO_TEST_MYSQL_COLLATIONS') ?: 'utf8mb4_general_ci,utf8mb4_unicode_ci'; + foreach (array_filter(array_map('trim', explode(',', $collations))) as $collation) { + $backends['mysql ' . $collation] = [[ + 'host' => $host, + 'port' => (int) (getenv('EVO_TEST_MYSQL_PORT') ?: 3306), + 'database' => getenv('EVO_TEST_MYSQL_DATABASE') ?: 'evo_test', + 'username' => getenv('EVO_TEST_MYSQL_USER') ?: 'root', + 'password' => (string) getenv('EVO_TEST_MYSQL_PASSWORD'), + 'collation' => $collation, + ]]; + } + + return $backends; +} + +function saveSettingsRun(string $site, array $permissions, array $post, ?array $mysql): array { $output = evoRunPhp(dirname(__DIR__, 2) . '/Fixtures/save-settings-security.php', [ - $site, base64_encode(json_encode(['permissions' => $permissions, 'post' => $post])), + $site, base64_encode(json_encode(['permissions' => $permissions, 'post' => $post, 'mysql' => $mysql])), ]); $decoded = json_decode($output, true); expect($decoded)->toBeArray($output); + // fields the fixture's form always carries; the processor stores them as it always did + foreach (['filemanager_path', 'friendly_urls', 'rb_base_dir', 'stay'] as $formField) { + unset($decoded['settings'][$formField]); + } return $decoded; } @@ -105,11 +162,31 @@ function saveSettingsChecksum(string $site, array $files): string return serialize($sums); } +/** Request names that a case- or accent-insensitive collation folds onto a stored or protected setting. */ +const SAVE_SETTINGS_VARIANTS = [ + 'USE_UDPERMS' => '0', + 'Allow_Eval' => 'everywhere', + 'use_udpérms' => '0', + 'ENABLE_BINDINGS' => '1', + 'Check_Files_Onlogin' => '', + 'SYS_FILES_CHECKSUM' => 'forged', + 'Settings_Version' => '0.0.1', + 'JPEGQUALITY' => '10', +]; + beforeEach(function () { $this->site = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-save-settings-' . bin2hex(random_bytes(6)); mkdir($this->site . '/manager', 0777, true); file_put_contents($this->site . '/index.php', 'site . '/manager/index.php', 'stored = [ + 'site_name' => 'Old name', + 'use_udperms' => '1', + 'allow_eval' => 'with_scan', + 'check_files_onlogin' => 'index.php', + 'sys_files_checksum' => 'stored-checksum', + 'jpegQuality' => '85', + ]; }); afterEach(function () { @@ -119,53 +196,65 @@ function saveSettingsChecksum(string $site, array $files): string @rmdir($this->site); }); -it('saves an ordinary form with the Security tab hidden and leaves the security settings alone', function () { - $result = saveSettingsRun($this->site, ['settings'], ['site_name' => 'New name']); +it('runs on a table that folds setting names the way the database does', function (?array $mysql) { + $folds = saveSettingsRun($this->site, ['settings'], [], $mysql)['folds']; + + expect($folds['USE_UDPERMS'])->toBeTrue(); + if ($mysql !== null) { + // accents and trailing spaces fold too: what the name rule has to keep out + expect($folds['use_udpérms'])->toBeTrue() + ->and($folds['use_udperms '])->toBeTrue(); + } +})->with(saveSettingsBackends()); + +it('saves an ordinary form with the Security tab hidden and leaves the security settings alone', function (?array $mysql) { + $result = saveSettingsRun($this->site, ['settings'], ['site_name' => 'New name', 'jpegQuality' => '90'], $mysql); expect($result['notices'])->toBe([]) - ->and($result['settings']['site_name'])->toBe('New name') - ->and($result['settings']['check_files_onlogin'])->toBe('index.php') - ->and($result['settings']['sys_files_checksum'])->toBe('stored-checksum') - ->and($result['settings']['use_udperms'])->toBe('1'); -}); + ->and($result['settings'])->toEqualCanonicalizing( + ['site_name' => 'New name', 'jpegQuality' => '90'] + $this->stored + ); +})->with(saveSettingsBackends()); -it('ignores security fields and the checksum in a crafted request without the permission', function () { +it('ignores security fields, the checksum and other spellings of them without the permission', function (?array $mysql) { $result = saveSettingsRun($this->site, ['settings'], [ 'site_name' => 'New name', 'use_udperms' => '0', 'allow_eval' => 'everywhere', 'check_files_onlogin' => '', 'sys_files_checksum' => 'forged', - ]); + ] + SAVE_SETTINGS_VARIANTS, $mysql); + // no row changed but the ordinary one, and none was added under another spelling expect($result['notices'])->toBe([]) - ->and($result['settings']['site_name'])->toBe('New name') - ->and($result['settings']['use_udperms'])->toBe('1') - ->and($result['settings']['allow_eval'])->toBe('with_scan') - ->and($result['settings']['check_files_onlogin'])->toBe('index.php') - ->and($result['settings']['sys_files_checksum'])->toBe('stored-checksum'); -}); + ->and($result['settings'])->toEqualCanonicalizing(['site_name' => 'New name'] + $this->stored); +})->with(saveSettingsBackends()); -it('saves the security settings and recomputes the checksum for a manager with the permission', function () { +it('saves the security settings and recomputes the checksum for a manager with the permission', function (?array $mysql) { $result = saveSettingsRun($this->site, ['settings', 'security_settings'], [ 'site_name' => 'New name', 'use_udperms' => '0', 'check_files_onlogin' => "index.php\nmanager/index.php", // the stored checksum is always the one computed from the list 'sys_files_checksum' => 'forged', - ]); + ] + SAVE_SETTINGS_VARIANTS, $mysql); + // only the stored spellings are written, for the administrator too expect($result['notices'])->toBe([]) - ->and($result['settings']['use_udperms'])->toBe('0') - ->and($result['settings']['check_files_onlogin'])->toBe("index.php\nmanager/index.php") - ->and($result['settings']['sys_files_checksum']) - ->toBe(saveSettingsChecksum($this->site, ['index.php', 'manager/index.php'])); -}); + ->and($result['settings'])->toEqualCanonicalizing([ + 'site_name' => 'New name', + 'use_udperms' => '0', + 'allow_eval' => 'with_scan', + 'check_files_onlogin' => "index.php\nmanager/index.php", + 'sys_files_checksum' => saveSettingsChecksum($this->site, ['index.php', 'manager/index.php']), + 'jpegQuality' => '85', + ]); +})->with(saveSettingsBackends()); -it('computes the checksum from the stored list when a permitted save does not carry it', function () { - $result = saveSettingsRun($this->site, ['settings', 'security_settings'], ['site_name' => 'New name']); +it('computes the checksum from the stored list when a permitted save does not carry it', function (?array $mysql) { + $result = saveSettingsRun($this->site, ['settings', 'security_settings'], ['site_name' => 'New name'], $mysql); expect($result['notices'])->toBe([]) ->and($result['settings']['check_files_onlogin'])->toBe('index.php') ->and($result['settings']['sys_files_checksum'])->toBe(saveSettingsChecksum($this->site, ['index.php'])); -}); +})->with(saveSettingsBackends()); diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index 8f259950ff..9b24d87bc8 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1465,8 +1465,10 @@ 'EvolutionCMS\\Support\\MysqlDumper' => $baseDir . '/src/Support/MysqlDumper.php', 'EvolutionCMS\\Support\\Paginate' => $baseDir . '/src/Support/Paginate.php', 'EvolutionCMS\\Support\\ResourceParentGuard' => $baseDir . '/src/Support/ResourceParentGuard.php', + 'EvolutionCMS\\Support\\SecuritySettings' => $baseDir . '/src/Support/SecuritySettings.php', 'EvolutionCMS\\Support\\SiteTimezone' => $baseDir . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => $baseDir . '/src/Support/SqliteDumper.php', + 'EvolutionCMS\\Support\\SystemSettingName' => $baseDir . '/src/Support/SystemSettingName.php', 'EvolutionCMS\\Support\\SystemSettingPathNormalizer' => $baseDir . '/src/Support/SystemSettingPathNormalizer.php', 'EvolutionCMS\\Support\\TemplateFileEngines' => $baseDir . '/src/Support/TemplateFileEngines.php', 'EvolutionCMS\\Support\\TemplateFileGuard' => $baseDir . '/src/Support/TemplateFileGuard.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index afe9485fab..6c44469301 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2132,8 +2132,10 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\MysqlDumper' => __DIR__ . '/../..' . '/src/Support/MysqlDumper.php', 'EvolutionCMS\\Support\\Paginate' => __DIR__ . '/../..' . '/src/Support/Paginate.php', 'EvolutionCMS\\Support\\ResourceParentGuard' => __DIR__ . '/../..' . '/src/Support/ResourceParentGuard.php', + 'EvolutionCMS\\Support\\SecuritySettings' => __DIR__ . '/../..' . '/src/Support/SecuritySettings.php', 'EvolutionCMS\\Support\\SiteTimezone' => __DIR__ . '/../..' . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => __DIR__ . '/../..' . '/src/Support/SqliteDumper.php', + 'EvolutionCMS\\Support\\SystemSettingName' => __DIR__ . '/../..' . '/src/Support/SystemSettingName.php', 'EvolutionCMS\\Support\\SystemSettingPathNormalizer' => __DIR__ . '/../..' . '/src/Support/SystemSettingPathNormalizer.php', 'EvolutionCMS\\Support\\TemplateFileEngines' => __DIR__ . '/../..' . '/src/Support/TemplateFileEngines.php', 'EvolutionCMS\\Support\\TemplateFileGuard' => __DIR__ . '/../..' . '/src/Support/TemplateFileGuard.php', diff --git a/install/stubs/seeds/install/UserPermissionsTableSeeder.php b/install/stubs/seeds/install/UserPermissionsTableSeeder.php index 1449f6e97b..2af6ef6183 100644 --- a/install/stubs/seeds/install/UserPermissionsTableSeeder.php +++ b/install/stubs/seeds/install/UserPermissionsTableSeeder.php @@ -46,10 +46,6 @@ public function run(): void 'group_id' => 1 ], ['name' => 'View help pages', 'lang_key' => 'role_help', 'key' => 'help', 'disabled' => 0, 'group_id' => 1], - [ - 'name' => 'View action completed screen', 'lang_key' => 'role_actionok', 'key' => 'action_ok', - 'disabled' => 1, 'group_id' => 1 - ], [ 'name' => 'View error dialog', 'lang_key' => 'role_errors', 'key' => 'error_dialog', 'disabled' => 1, 'group_id' => 1 @@ -307,6 +303,10 @@ public function run(): void 'name' => 'Change site settings', 'key' => 'settings', 'lang_key' => 'role_edit_settings', 'disabled' => 0, 'group_id' => 14 ], + [ + 'name' => 'Change security settings', 'key' => 'security_settings', 'lang_key' => 'role_security_settings', + 'disabled' => 0, 'group_id' => 14 + ], [ 'name' => 'Use the Backup Manager', 'key' => 'bk_manager', 'lang_key' => 'role_bk_manager', 'disabled' => 0, 'group_id' => 14 diff --git a/install/stubs/seeds/install/UserRolesTableSeeder.php b/install/stubs/seeds/install/UserRolesTableSeeder.php index 42b9160c5d..ef77acc9d1 100644 --- a/install/stubs/seeds/install/UserRolesTableSeeder.php +++ b/install/stubs/seeds/install/UserRolesTableSeeder.php @@ -35,7 +35,6 @@ public function run(): void ['permission' => 'home', 'role_id' => 1], ['permission' => 'widget_recent_info', 'role_id' => 1], ['permission' => 'help', 'role_id' => 1], - ['permission' => 'role_actionok', 'role_id' => 1], ['permission' => 'error_dialog', 'role_id' => 1], ['permission' => 'about', 'role_id' => 1], ['permission' => 'widget_online_info', 'role_id' => 1], @@ -94,6 +93,7 @@ public function run(): void ['permission' => 'delete_eventlog', 'role_id' => 1], ['permission' => 'logs', 'role_id' => 1], ['permission' => 'settings', 'role_id' => 1], + ['permission' => 'security_settings', 'role_id' => 1], ['permission' => 'bk_manager', 'role_id' => 1], ['permission' => 'remove_locks', 'role_id' => 1], ['permission' => 'display_locks', 'role_id' => 1], @@ -104,7 +104,6 @@ public function run(): void ['permission' => 'home', 'role_id' => 2], ['permission' => 'widget_recent_info', 'role_id' => 2], ['permission' => 'help', 'role_id' => 2], - ['permission' => 'role_actionok', 'role_id' => 2], ['permission' => 'error_dialog', 'role_id' => 2], ['permission' => 'about', 'role_id' => 2], ['permission' => 'widget_online_info', 'role_id' => 2], @@ -137,7 +136,6 @@ public function run(): void ['permission' => 'home', 'role_id' => 3], ['permission' => 'widget_recent_info', 'role_id' => 3], ['permission' => 'help', 'role_id' => 3], - ['permission' => 'role_actionok', 'role_id' => 3], ['permission' => 'error_dialog', 'role_id' => 3], ['permission' => 'about', 'role_id' => 3], ['permission' => 'widget_online_info', 'role_id' => 3], diff --git a/manager/processors/save_settings.processor.php b/manager/processors/save_settings.processor.php index 53f626e4ee..fbb2da8bf1 100755 --- a/manager/processors/save_settings.processor.php +++ b/manager/processors/save_settings.processor.php @@ -54,20 +54,35 @@ } } $mayEditSecurity = $modx->hasPermission('security_settings'); + foreach (array_keys($data) as $k) { + if (strcasecmp((string) $k, 'sys_files_checksum') === 0) { + unset($data[$k]); + } + } + // Stored rows, factory defaults (merged into the config), the gated and the derived names: + // each is stored under its own spelling only, even before a row exists. + $knownSettingNames = array_merge( + \EvolutionCMS\Models\SystemSetting::query()->pluck('setting_name')->all(), + array_map('strval', array_keys($modx->config)), + \EvolutionCMS\Support\SecuritySettings::GATED, + ['sys_files_checksum'] + ); if ($mayEditSecurity) { $data['sys_files_checksum'] = $modx->getManagerApi()->getSystemChecksum( (string) ($data['check_files_onlogin'] ?? $modx->getConfig('check_files_onlogin', '')) ); - } else { - // The checksum belongs to check_files_onlogin on the Security tab: without the permission - // both stay as stored, whatever the request carries. - unset($data['sys_files_checksum']); } + // Without the permission check_files_onlogin (gated below) and its checksum stay as stored, + // whatever the request carries. foreach ($data as $k => $v) { if (isset($defaultSettings[$k])) { continue; } + if (!\EvolutionCMS\Support\SystemSettingName::storable((string) $k, $knownSettingNames)) { + continue; + } + // The Security tab (ACL toggles, eval and login hardening) needs its own permission on top // of `settings`; skip those keys without it so a crafted POST cannot set them. The form also // hides the tab, so an ordinary save never carries them. From ea88047e71910b0a8922f4c6eb9e42d75cb6f1cd Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 17:25:56 +0100 Subject: [PATCH 46/50] fix(manager): keep the file integrity check quiet after an update Every update replaces index.php and manager/index.php with the release's copies, and the dashboard then reported both as modified. The dashboard check now first records a listed file whose stored hash and current hash are both copies the CMS shipped (SystemFilesChecksum::RELEASED, every copy since 3.0), the current one this code's. A file anyone edited, before or after the update, or swapped for an older copy, is still reported. This works for make:site, the web updater, an overlay or git alike. The reset link of that warning changed state by GET without a CSRF token; it now carries one and a=2 ?b= is a guarded parameter. Co-Authored-By: Claude Opus 5.5 --- core/src/Middleware/VerifyCsrfToken.php | 1 + core/src/Support/SystemFilesChecksum.php | 108 +++++++++ core/tests/Fixtures/system-files-checksum.php | 32 +++ .../Unit/Security/ManagerCsrfCoverageTest.php | 1 + .../Unit/Security/SystemFilesChecksumTest.php | 212 ++++++++++++++++++ core/vendor/composer/autoload_classmap.php | 1 + core/vendor/composer/autoload_static.php | 1 + manager/includes/config_check.inc.php | 14 +- 8 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 core/src/Support/SystemFilesChecksum.php create mode 100644 core/tests/Fixtures/system-files-checksum.php create mode 100644 core/tests/Unit/Security/SystemFilesChecksumTest.php diff --git a/core/src/Middleware/VerifyCsrfToken.php b/core/src/Middleware/VerifyCsrfToken.php index 1d64b7fc16..412fcd2284 100644 --- a/core/src/Middleware/VerifyCsrfToken.php +++ b/core/src/Middleware/VerifyCsrfToken.php @@ -74,6 +74,7 @@ class VerifyCsrfToken * edit forms, so only requests carrying the triggering parameter are verified. */ protected const MUTATING_GET_PARAMETERS = [ + 2 => 'b', // dashboard - ?b=resetSysfilesChecksum accepts the checked files as they are 35 => 'action', // UserRole - ?action=delete removes the role 36 => 'action', // UserRole 38 => 'action', // UserRole diff --git a/core/src/Support/SystemFilesChecksum.php b/core/src/Support/SystemFilesChecksum.php new file mode 100644 index 0000000000..e7f9c6c2d3 --- /dev/null +++ b/core/src/Support/SystemFilesChecksum.php @@ -0,0 +1,108 @@ + + */ + public const RELEASED = [ + 'index.php' => [ + '589eb44fffb0de5559311ada637bdfae', // 3.0RC + '2bb924454f81f4a2f90e1124d73d0d12', // 3.0RC2 + '599511a98673763d066ea76b9e50fbb0', // 3.0RC3 - 3.2.5 + '4452ae4b9b9c9aff195991f46c90f79e', // 3.2.6 - 3.5.0 + '013001e96f9ac1031742413b7903641b', + 'f38ddec531ed48e89230818d2827ce26', + 'bb682f550e906c0b82d189e8f9ff70aa', // 3.5.1 - 3.5.2 + '1e9e549bf7a22145e27c2b733d26d025', + '928f5b3af00fdf9847d1bc44754e9cd1', + 'e4eeeb5c1a78c2f03526360bfb0c3568', + 'ef67b3a006d8f9728c8d6a8eb7e7640c', + '726d910bdd90f0caef4507a7d65102b8', + '87c8b2cb7cbe7b98c831cd9c56531149', // 3.5.3 + '949f5a6f1ddaef3ce8049b5b99a5481d', + '069f903824f213e84924cfe999e81ff1', + '46c2370a6df13f6438390dba94e10266', // 3.5.4 + 'cf45d727110f24e2af97fe71c9f5669f', + '29bcd9deaa5255b271b06d8261ec5966', // 3.5.5 + 'e845a31b74e249d433dc36badedfae29', // 3.5.6 - 3.5.7 + '11727c69a7f8c012bccd41524ca57d6d', // 3.5.8 + '73555b5a990998c9aa419a98150ca9f8', // 3.5.9 + ], + 'manager/index.php' => [ + '3b1215f626722408dd6ffd7a8f16da9a', // 3.0RC + '325d82eed384dcb95528d8ffb36d17a1', // 3.0RC2 - 3.1.3 + 'e00ab1a8d239ad179954f5dbb0afb6e8', + 'b2e01536079e041f49b4faea638645e4', + '3d47b1a1cd56ebbaf67ba2a88fda41bc', + 'c467fb824dc6e2df167e8d45d1919b0b', + '19c60de055d5efeb3fdd9debde124cb7', // 3.1.4 - 3.2.7 + '42ef80570fd86e06b10f8425239ac375', // 3.3.0 - 3.5.0 + '45523481877a4160b742fd410f541903', // 3.5.1 + '976e13c4c0174d70e91626b890be0548', // 3.5.2 + 'a7cae3062ec74f8261fd9ddf5ab8b0e5', + 'ba436e70af1f674813ee01a6ab9a6247', + '343d20fc20f5f8e3abca27631242c588', + 'bccc8c37ddccf33590e125ec0bf67701', // 3.5.3 + 'fa68033f07a081da54e32b16b89741dd', + 'ab14a72c71f97945e6d90792a950c4a5', // 3.5.4 - 3.5.8 + 'c4da7b5495dc050db774a7f6171284c4', + 'a6be82138f1d5bf7ac980242a9d1b07f', // 3.5.9 + '35f96ba44e454449aeef6ed9cc13a786', + ], + ]; + + /** + * The stored checksum with the release copies of this code adopted, or null when nothing + * changes (no stored checksum, or no listed file was replaced by an update). + * + * Entries are keyed as ManagerApi::getSystemChecksum() keys them: $basePath and the listed name. + * + * @param array|null $released defaults to RELEASED + */ + public static function adopt(string $checkFiles, string $stored, string $basePath, ?array $released = null): ?string + { + $released ??= self::RELEASED; + $checksum = $stored === '' ? false : @unserialize($stored, ['allowed_classes' => false]); + if (!is_array($checksum) || $checksum === []) { + return null; + } + + $changed = false; + foreach (explode("\n", trim($checkFiles)) as $file) { + $file = trim($file); + $copies = $released[ltrim(str_replace('\\', '/', $file), '/')] ?? []; + $path = $basePath . $file; + if ($copies === [] || !is_file($path) || !isset($checksum[$path])) { + continue; + } + $current = md5_file($path); + if ($current !== $checksum[$path] + && $current === end($copies) + && in_array($checksum[$path], $copies, true) + ) { + $checksum[$path] = $current; + $changed = true; + } + } + + return $changed ? serialize($checksum) : null; + } +} diff --git a/core/tests/Fixtures/system-files-checksum.php b/core/tests/Fixtures/system-files-checksum.php new file mode 100644 index 0000000000..fe782f2a78 --- /dev/null +++ b/core/tests/Fixtures/system-files-checksum.php @@ -0,0 +1,32 @@ +newInstanceWithoutConstructor(); +$adopted = EvolutionCMS\Support\SystemFilesChecksum::adopt( + $options['list'], + $options['stored'], + EVO_BASE_PATH, + $options['released'] ?? null +); + +echo json_encode([ + 'adopted' => $adopted, + 'current' => $api->getSystemChecksum($options['list']), + 'before' => $api->getModifiedSystemFilesList($options['list'], $options['stored']), + 'after' => $api->getModifiedSystemFilesList($options['list'], $adopted ?? $options['stored']), +]); diff --git a/core/tests/Unit/Security/ManagerCsrfCoverageTest.php b/core/tests/Unit/Security/ManagerCsrfCoverageTest.php index d054c2a37d..4e9b1e1085 100644 --- a/core/tests/Unit/Security/ManagerCsrfCoverageTest.php +++ b/core/tests/Unit/Security/ManagerCsrfCoverageTest.php @@ -227,6 +227,7 @@ function isLoginTemplate(string $path): bool 'core/src/Controllers/UserRoles/Permission.php' => [[135], 'action', "\$_GET['action'] == 'delete'"], 'core/src/Controllers/UserRoles/PermissionsGroups.php' => [[136], 'action', "\$_GET['action'] == 'delete'"], 'core/src/Controllers/SystemInfo.php' => [[53], 'opcache_reset', "request()->boolean('opcache_reset')"], + 'manager/includes/config_check.inc.php' => [[2], 'b', "\$_REQUEST['b'] == 'resetSysfilesChecksum'"], 'manager/actions/mutate_module_resources.dynamic.php' => [[113], 'op', "switch (\$_REQUEST['op'])"], 'manager/actions/category_mgr/inc/request_trigger.inc.php' => [ [120, 121], diff --git a/core/tests/Unit/Security/SystemFilesChecksumTest.php b/core/tests/Unit/Security/SystemFilesChecksumTest.php new file mode 100644 index 0000000000..c919e0d6bc --- /dev/null +++ b/core/tests/Unit/Security/SystemFilesChecksumTest.php @@ -0,0 +1,212 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + return null; + } + $content = (string) stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + + return proc_close($process) === 0 ? $content : null; +} + +/** @param array $files relative name => content */ +function sysFilesWrite(string $site, array $files): void +{ + foreach ($files as $name => $content) { + @mkdir(dirname($site . $name), 0777, true); + file_put_contents($site . $name, $content); + } +} + +/** The md5 map ManagerApi::getSystemChecksum() stores, written out by hand. */ +function sysFilesChecksum(string $site, array $names): string +{ + $sums = []; + foreach ($names as $name) { + $sums[$site . $name] = md5_file($site . $name); + } + + return serialize($sums); +} + +/** adopt() and the dashboard's comparison, see Fixtures/system-files-checksum.php */ +function sysFilesCheck(string $site, string $list, string $stored, ?array $released = null): array +{ + $output = evoRunPhp(dirname(__DIR__, 2) . '/Fixtures/system-files-checksum.php', [ + $site, base64_encode(json_encode(['list' => $list, 'stored' => $stored, 'released' => $released])), + ]); + $decoded = json_decode($output, true); + expect($decoded)->toBeArray($output); + + return $decoded; +} + +const SYS_FILES_LIST = "index.php\n.htaccess\nmanager/index.php"; + +beforeEach(function () { + $this->site = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-sysfiles-' . bin2hex(random_bytes(6)) . '/'; + // three copies of index.php and two of manager/index.php, the last of each this code's + $this->released = [ + 'index.php' => [md5('index 1'), md5('index 2'), md5('index 3')], + 'manager/index.php' => [md5('manager 1'), md5('manager 2')], + ]; + sysFilesWrite($this->site, ['index.php' => 'index 2', 'manager/index.php' => 'manager 1', '.htaccess' => 'rules']); + $this->stored = sysFilesChecksum($this->site, ['index.php', '.htaccess', 'manager/index.php']); +}); + +afterEach(function () { + foreach (['index.php', 'manager/index.php', '.htaccess'] as $name) { + @unlink($this->site . $name); + } + @rmdir($this->site . 'manager'); + @rmdir($this->site); +}); + +it('records the release copies an update laid over untouched files', function () { + sysFilesWrite($this->site, ['index.php' => 'index 3', 'manager/index.php' => 'manager 2']); + + $result = sysFilesCheck($this->site, SYS_FILES_LIST, $this->stored, $this->released); + + expect($result['before'])->toBe(['index.php', 'manager/index.php']) + ->and($result['adopted'])->toBe(sysFilesChecksum($this->site, ['index.php', '.htaccess', 'manager/index.php'])) + ->and($result['after'])->toBe([]) + // what checkSystemChecksum() compares first + ->and($result['current'])->toBe($result['adopted']); +}); + +it('still reports a file edited by someone', function (array $files, ?array $baseline, array $reported) { + if ($baseline !== null) { + sysFilesWrite($this->site, $baseline); + $this->stored = sysFilesChecksum($this->site, ['index.php', '.htaccess', 'manager/index.php']); + } + sysFilesWrite($this->site, $files); + + expect(sysFilesCheck($this->site, SYS_FILES_LIST, $this->stored, $this->released)['after'])->toBe($reported); +})->with([ + 'edited, no update' => [['index.php' => 'index 2 + backdoor'], null, ['index.php']], + 'edited after the update' => [['index.php' => 'index 3 + backdoor'], null, ['index.php']], + // the baseline was taken of an edited copy, the update then replaced it + 'edited before the update' => [['index.php' => 'index 3'], ['index.php' => 'custom index'], ['index.php']], + 'replaced by an older release copy' => [['index.php' => 'index 1'], null, ['index.php']], + 'a file no release ships' => [['.htaccess' => 'rules + redirect'], null, ['.htaccess']], + 'one replaced by the update, one edited' => [ + ['index.php' => 'index 3', '.htaccess' => 'rules + redirect'], null, ['.htaccess'], + ], +]); + +it('leaves the checksum alone when there is nothing to adopt', function (string $stored) { + sysFilesWrite($this->site, ['index.php' => 'index 3']); + + expect(SystemFilesChecksum::adopt(SYS_FILES_LIST, $stored, $this->site, $this->released))->toBeNull(); +})->with([ + 'no checksum yet' => [''], + 'not a checksum' => ['O:8:"stdClass":0:{}'], + 'files not in the map' => [serialize(['/elsewhere/index.php' => md5('index 2')])], +]); + +it('lists every shipped file of the default check list, this code\'s copy last', function () { + $defaults = (string) file_get_contents(sysFilesRoot() . '/core/factory/settings.php'); + preg_match("/'check_files_onlogin' => \"([^\"]+)\"/", $defaults, $match); + // .htaccess and the database config are the site's own, no release writes them + $shipped = array_values(array_filter( + array_map(fn ($file) => ltrim(trim($file), '/'), explode('\n', $match[1])), + fn ($file) => is_file(sysFilesRoot() . '/' . $file) && !str_starts_with($file, '.') && !str_starts_with($file, 'core/config/') + )); + + expect($shipped)->toBe(['index.php', 'manager/index.php']) + ->and(array_keys(SystemFilesChecksum::RELEASED))->toBe($shipped); + foreach ($shipped as $file) { + $copies = SystemFilesChecksum::RELEASED[$file]; + expect(end($copies))->toBe( + sysFilesShippedMd5($file), + "{$file} changed: append " . sysFilesShippedMd5($file) . " to SystemFilesChecksum::RELEASED['{$file}']" + ) + ->and(array_values(array_unique($copies)))->toBe($copies) + ->and(array_values(preg_grep('/^[0-9a-f]{32}$/', $copies)))->toBe($copies); + } +}); + +it('knows the copies of every 3.x release, so an update from any of them is quiet', function () { + $root = escapeshellarg(sysFilesRoot()); + $tags = array_filter(explode("\n", (string) evoRunCommand("git -C {$root} tag --merged HEAD --list \"3.*\" 2>&1", $exitCode))); + if ($exitCode !== 0 || !in_array('3.5.9', $tags, true)) { + $this->markTestSkipped('no git checkout with the release tags'); + } + + $unknown = []; + foreach ($tags as $tag) { + foreach (array_keys(SystemFilesChecksum::RELEASED) as $file) { + $copy = sysFilesGitShow($tag, $file); + if ($copy !== null && !in_array(md5($copy), SystemFilesChecksum::RELEASED[$file], true)) { + $unknown[] = $tag . ':' . $file; + } + } + } + + expect($unknown)->toBe([]); +}); + +it('stays quiet over a real update from 3.5.9 to this code', function () { + $files = []; + foreach (array_keys(SystemFilesChecksum::RELEASED) as $file) { + $files[$file] = sysFilesGitShow('3.5.9', $file); + if ($files[$file] === null) { + $this->markTestSkipped('no git checkout with the 3.5.9 tag'); + } + } + sysFilesWrite($this->site, $files); + $list = "index.php\n.htaccess\nmanager/index.php\n/core/config/database/connections/default.php"; + $stored = sysFilesChecksum($this->site, ['index.php', '.htaccess', 'manager/index.php']); + + // the update lays this code's copies over the site + foreach (array_keys($files) as $file) { + file_put_contents($this->site . $file, str_replace("\r\n", "\n", (string) file_get_contents(sysFilesRoot() . '/' . $file))); + } + $result = sysFilesCheck($this->site, $list, $stored); + + expect($result['before'])->not->toBe([]) + ->and($result['adopted'])->not->toBeNull() + ->and($result['after'])->toBe([]); +}); + +it('adopts on the dashboard check, before the files are compared', function () { + $source = (string) file_get_contents(sysFilesRoot() . '/manager/includes/config_check.inc.php'); + $adopt = strpos($source, 'SystemFilesChecksum::adopt('); + + expect($adopt)->toBeInt() + ->and($adopt)->toBeLessThan(strpos($source, 'checkSystemChecksum()')) + ->and($source)->toContain('setSystemChecksum($adoptedSysfilesChecksum)') + // the reset link is a state change by GET + ->and($source)->toContain('b=resetSysfilesChecksum&_token=\' . csrf_token()'); +}); diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index 9b24d87bc8..b34c4c0b8f 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1468,6 +1468,7 @@ 'EvolutionCMS\\Support\\SecuritySettings' => $baseDir . '/src/Support/SecuritySettings.php', 'EvolutionCMS\\Support\\SiteTimezone' => $baseDir . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => $baseDir . '/src/Support/SqliteDumper.php', + 'EvolutionCMS\\Support\\SystemFilesChecksum' => $baseDir . '/src/Support/SystemFilesChecksum.php', 'EvolutionCMS\\Support\\SystemSettingName' => $baseDir . '/src/Support/SystemSettingName.php', 'EvolutionCMS\\Support\\SystemSettingPathNormalizer' => $baseDir . '/src/Support/SystemSettingPathNormalizer.php', 'EvolutionCMS\\Support\\TemplateFileEngines' => $baseDir . '/src/Support/TemplateFileEngines.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index 6c44469301..6be92ff4d0 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2135,6 +2135,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\SecuritySettings' => __DIR__ . '/../..' . '/src/Support/SecuritySettings.php', 'EvolutionCMS\\Support\\SiteTimezone' => __DIR__ . '/../..' . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => __DIR__ . '/../..' . '/src/Support/SqliteDumper.php', + 'EvolutionCMS\\Support\\SystemFilesChecksum' => __DIR__ . '/../..' . '/src/Support/SystemFilesChecksum.php', 'EvolutionCMS\\Support\\SystemSettingName' => __DIR__ . '/../..' . '/src/Support/SystemSettingName.php', 'EvolutionCMS\\Support\\SystemSettingPathNormalizer' => __DIR__ . '/../..' . '/src/Support/SystemSettingPathNormalizer.php', 'EvolutionCMS\\Support\\TemplateFileEngines' => __DIR__ . '/../..' . '/src/Support/TemplateFileEngines.php', diff --git a/manager/includes/config_check.inc.php b/manager/includes/config_check.inc.php index 1a660d64ff..5133044385 100755 --- a/manager/includes/config_check.inc.php +++ b/manager/includes/config_check.inc.php @@ -17,6 +17,18 @@ // NOW CHECK CONFIG $warnings = []; +// An update replaces index.php and manager/index.php with this release's copies: record them +// rather than report them as modified. +$adoptedSysfilesChecksum = \EvolutionCMS\Support\SystemFilesChecksum::adopt( + (string) EvolutionCMS()->getConfig('check_files_onlogin', ''), + (string) EvolutionCMS()->getConfig('sys_files_checksum', ''), + EVO_BASE_PATH +); +if ($adoptedSysfilesChecksum !== null) { + EvolutionCMS()->getManagerApi()->setSystemChecksum($adoptedSysfilesChecksum); + EvolutionCMS()->config['sys_files_checksum'] = $adoptedSysfilesChecksum; + EvolutionCMS()->clearCache('full'); +} $sysfiles_check = EvolutionCMS()->getManagerApi()->checkSystemChecksum(); if ($sysfiles_check!=='0') { $warnings[] = [$_lang['configcheck_sysfiles_mod']]; @@ -187,7 +199,7 @@ function checkSiteCache() { $warnings[$i][1] = $_lang["configcheck_sysfiles_mod_msg"]; $warnings[$i][2] = '
  • '. implode('
  • ', $sysfiles_check) .'
'; if(EvolutionCMS()->hasPermission('settings')) { - $warnings[$i][2] .= ''; + $warnings[$i][2] .= ''; } if(empty($_SESSION["mgrConfigCheck"])) EvolutionCMS()->logEvent(0,3,$warnings[$i][1]." ".implode(', ',$sysfiles_check),$_lang['configcheck_sysfiles_mod']); break; From b65b59a09e0bbfc190d9e0dbf1aa451b415d6d91 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 17:26:43 +0100 Subject: [PATCH 47/50] fix(autoload): add the 3.5.x classes missing from the committed classmap ComposerEnvironment, DocumentCascadeGuard, RoleGuard and UserGroupGuard came in with recent 3.5.x merges without a classmap entry, so a site on the committed classmap-authoritative vendor fails with class not found where they are used. CommittedClassmapTest reports them. Co-Authored-By: Claude Opus 5.5 --- core/vendor/composer/autoload_classmap.php | 4 ++++ core/vendor/composer/autoload_static.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index b34c4c0b8f..7b5945ff22 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1394,6 +1394,7 @@ 'EvolutionCMS\\ServiceProvider' => $baseDir . '/src/ServiceProvider.php', 'EvolutionCMS\\Services\\AuthServices' => $baseDir . '/src/Services/AuthServices.php', 'EvolutionCMS\\Services\\BackgroundDatabaseBackup' => $baseDir . '/src/Services/BackgroundDatabaseBackup.php', + 'EvolutionCMS\\Services\\ComposerEnvironment' => $baseDir . '/src/Services/ComposerEnvironment.php', 'EvolutionCMS\\Services\\ComposerVersionSynchronizer' => $baseDir . '/src/Services/ComposerVersionSynchronizer.php', 'EvolutionCMS\\Services\\ConfigService' => $baseDir . '/src/Services/ConfigService.php', 'EvolutionCMS\\Services\\CustomComposerPackages' => $baseDir . '/src/Services/CustomComposerPackages.php', @@ -1441,6 +1442,7 @@ 'EvolutionCMS\\Support\\DataGrid' => $baseDir . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => $baseDir . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => $baseDir . '/src/Support/DocBlock.php', + 'EvolutionCMS\\Support\\DocumentCascadeGuard' => $baseDir . '/src/Support/DocumentCascadeGuard.php', 'EvolutionCMS\\Support\\DocumentPrivacy' => $baseDir . '/src/Support/DocumentPrivacy.php', 'EvolutionCMS\\Support\\DocumentSave\\DocumentGroupSync' => $baseDir . '/src/Support/DocumentSave/DocumentGroupSync.php', 'EvolutionCMS\\Support\\DocumentSave\\PublishState' => $baseDir . '/src/Support/DocumentSave/PublishState.php', @@ -1465,6 +1467,7 @@ 'EvolutionCMS\\Support\\MysqlDumper' => $baseDir . '/src/Support/MysqlDumper.php', 'EvolutionCMS\\Support\\Paginate' => $baseDir . '/src/Support/Paginate.php', 'EvolutionCMS\\Support\\ResourceParentGuard' => $baseDir . '/src/Support/ResourceParentGuard.php', + 'EvolutionCMS\\Support\\RoleGuard' => $baseDir . '/src/Support/RoleGuard.php', 'EvolutionCMS\\Support\\SecuritySettings' => $baseDir . '/src/Support/SecuritySettings.php', 'EvolutionCMS\\Support\\SiteTimezone' => $baseDir . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => $baseDir . '/src/Support/SqliteDumper.php', @@ -1476,6 +1479,7 @@ 'EvolutionCMS\\Support\\TvBindingArguments' => $baseDir . '/src/Support/TvBindingArguments.php', 'EvolutionCMS\\Support\\TvBindingGuard' => $baseDir . '/src/Support/TvBindingGuard.php', 'EvolutionCMS\\Support\\UploadLimits' => $baseDir . '/src/Support/UploadLimits.php', + 'EvolutionCMS\\Support\\UserGroupGuard' => $baseDir . '/src/Support/UserGroupGuard.php', 'EvolutionCMS\\TemplateProcessor' => $baseDir . '/src/TemplateProcessor.php', 'EvolutionCMS\\Tracy\\ConnectionTiming' => $baseDir . '/src/Tracy/ConnectionTiming.php', 'EvolutionCMS\\Tracy\\Debugger' => $baseDir . '/src/Tracy/Debugger.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index 6be92ff4d0..e43b1a802b 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2061,6 +2061,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\ServiceProvider' => __DIR__ . '/../..' . '/src/ServiceProvider.php', 'EvolutionCMS\\Services\\AuthServices' => __DIR__ . '/../..' . '/src/Services/AuthServices.php', 'EvolutionCMS\\Services\\BackgroundDatabaseBackup' => __DIR__ . '/../..' . '/src/Services/BackgroundDatabaseBackup.php', + 'EvolutionCMS\\Services\\ComposerEnvironment' => __DIR__ . '/../..' . '/src/Services/ComposerEnvironment.php', 'EvolutionCMS\\Services\\ComposerVersionSynchronizer' => __DIR__ . '/../..' . '/src/Services/ComposerVersionSynchronizer.php', 'EvolutionCMS\\Services\\ConfigService' => __DIR__ . '/../..' . '/src/Services/ConfigService.php', 'EvolutionCMS\\Services\\CustomComposerPackages' => __DIR__ . '/../..' . '/src/Services/CustomComposerPackages.php', @@ -2108,6 +2109,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\DataGrid' => __DIR__ . '/../..' . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => __DIR__ . '/../..' . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => __DIR__ . '/../..' . '/src/Support/DocBlock.php', + 'EvolutionCMS\\Support\\DocumentCascadeGuard' => __DIR__ . '/../..' . '/src/Support/DocumentCascadeGuard.php', 'EvolutionCMS\\Support\\DocumentPrivacy' => __DIR__ . '/../..' . '/src/Support/DocumentPrivacy.php', 'EvolutionCMS\\Support\\DocumentSave\\DocumentGroupSync' => __DIR__ . '/../..' . '/src/Support/DocumentSave/DocumentGroupSync.php', 'EvolutionCMS\\Support\\DocumentSave\\PublishState' => __DIR__ . '/../..' . '/src/Support/DocumentSave/PublishState.php', @@ -2132,6 +2134,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\MysqlDumper' => __DIR__ . '/../..' . '/src/Support/MysqlDumper.php', 'EvolutionCMS\\Support\\Paginate' => __DIR__ . '/../..' . '/src/Support/Paginate.php', 'EvolutionCMS\\Support\\ResourceParentGuard' => __DIR__ . '/../..' . '/src/Support/ResourceParentGuard.php', + 'EvolutionCMS\\Support\\RoleGuard' => __DIR__ . '/../..' . '/src/Support/RoleGuard.php', 'EvolutionCMS\\Support\\SecuritySettings' => __DIR__ . '/../..' . '/src/Support/SecuritySettings.php', 'EvolutionCMS\\Support\\SiteTimezone' => __DIR__ . '/../..' . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => __DIR__ . '/../..' . '/src/Support/SqliteDumper.php', @@ -2143,6 +2146,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\TvBindingArguments' => __DIR__ . '/../..' . '/src/Support/TvBindingArguments.php', 'EvolutionCMS\\Support\\TvBindingGuard' => __DIR__ . '/../..' . '/src/Support/TvBindingGuard.php', 'EvolutionCMS\\Support\\UploadLimits' => __DIR__ . '/../..' . '/src/Support/UploadLimits.php', + 'EvolutionCMS\\Support\\UserGroupGuard' => __DIR__ . '/../..' . '/src/Support/UserGroupGuard.php', 'EvolutionCMS\\TemplateProcessor' => __DIR__ . '/../..' . '/src/TemplateProcessor.php', 'EvolutionCMS\\Tracy\\ConnectionTiming' => __DIR__ . '/../..' . '/src/Tracy/ConnectionTiming.php', 'EvolutionCMS\\Tracy\\Debugger' => __DIR__ . '/../..' . '/src/Tracy/Debugger.php', From 47b63f7a73c07b99c4d8e983d8b672c7637a541d Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 17:33:53 +0100 Subject: [PATCH 48/50] upd(sync): list the 3.6 copy of index.php as a release copy SystemFilesChecksumTest requires this code's copy last; 3.6's index.php differs from 3.5's, manager/index.php does not. Co-Authored-By: Claude Opus 5.5 --- core/src/Support/SystemFilesChecksum.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/Support/SystemFilesChecksum.php b/core/src/Support/SystemFilesChecksum.php index e7f9c6c2d3..111ba0d1ba 100644 --- a/core/src/Support/SystemFilesChecksum.php +++ b/core/src/Support/SystemFilesChecksum.php @@ -17,7 +17,7 @@ final class SystemFilesChecksum { /** * The md5 of every copy of a file the CMS ships, oldest first, since 3.0: releases and the - * commits of the 3.5 line. The last entry is the copy of this code; whoever changes the file + * commits of the 3.5 and 3.6 lines. The last entry is the copy of this code; whoever changes the file * appends its new md5 (SystemFilesChecksumTest says which). * * @var array @@ -45,6 +45,7 @@ final class SystemFilesChecksum 'e845a31b74e249d433dc36badedfae29', // 3.5.6 - 3.5.7 '11727c69a7f8c012bccd41524ca57d6d', // 3.5.8 '73555b5a990998c9aa419a98150ca9f8', // 3.5.9 + '553c79eefae71611e57072b54a603a67', ], 'manager/index.php' => [ '3b1215f626722408dd6ffd7a8f16da9a', // 3.0RC From b64350b2b2cc937646130f6bd758624b3f76c974 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 17:37:59 +0100 Subject: [PATCH 49/50] upd(sync): give the save_settings fixture the ManagerTheme facade of 3.6 On 3.6 the processor removes the theme's compiled stylesheet through the ManagerTheme facade, which the stand-alone fixture has no application for. Co-Authored-By: Claude Opus 5.5 --- core/tests/Fixtures/save-settings-security.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/tests/Fixtures/save-settings-security.php b/core/tests/Fixtures/save-settings-security.php index 7ca04c99c7..32999c0d5c 100644 --- a/core/tests/Fixtures/save-settings-security.php +++ b/core/tests/Fixtures/save-settings-security.php @@ -115,6 +115,20 @@ function config($key = null, $default = null) return $key === 'cms.settings' ? [] : $default; } +// the processor drops the theme's compiled stylesheet through the ManagerTheme facade +$container = new Illuminate\Container\Container(); +$container->instance('ManagerTheme', new class ($site) { + public function __construct(private string $site) + { + } + + public function getThemeDir(): string + { + return $this->site . '/manager/media/style/default/'; + } +}); +Illuminate\Support\Facades\Facade::setFacadeApplication($container); + $notices = []; set_error_handler(static function ($severity, $message, $file, $line) use (&$notices) { $notices[] = $message . ' @ ' . basename($file) . ':' . $line; From adb4e31de29b54160959d353da79d8418662bcc9 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 18:07:23 +0100 Subject: [PATCH 50/50] fix(acl): judge role reach by defined, optional permissions only Saving, deleting or assigning a role compared the target role's stored grants with the actor's defined permission keys. Sites installed with the earlier default seeders still give every default role role_actionok, a key no permission defines and no update removes, so a role manager holding every real permission of such a role was refused it. An always-on permission (action_ok on those sites) stored on the target by a later save but not on the actor's role blocked the same way. RoleGuard::reachOf() returns a role's grants of defined permissions that a role may hold or not, and both reach checks (role save/delete, role assignment on user save) use it. Every real permission still counts. Co-Authored-By: Claude Opus 5.5 --- core/src/Controllers/UserRoles/UserRole.php | 2 +- core/src/Controllers/Users/EditOrNewUser.php | 3 +- core/src/Support/RoleGuard.php | 20 ++ .../Security/RoleReachLegacyGrantsTest.php | 185 ++++++++++++++++++ 4 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 core/tests/Unit/Security/RoleReachLegacyGrantsTest.php diff --git a/core/src/Controllers/UserRoles/UserRole.php b/core/src/Controllers/UserRoles/UserRole.php index deeb19fc0a..07e6f0eb8d 100644 --- a/core/src/Controllers/UserRoles/UserRole.php +++ b/core/src/Controllers/UserRoles/UserRole.php @@ -248,7 +248,7 @@ protected function guardRoleReach(int $id, ?int $actorRole = null, ?array $actor { $actorRole = $actorRole ?? (int) ($_SESSION['mgrRole'] ?? 0); $actorPermissions = $actorPermissions ?? $this->actorPermissions(); - $targetPermissions = Models\RolePermissions::query()->where('role_id', $id)->pluck('permission')->all(); + $targetPermissions = RoleGuard::reachOf($id); if (!RoleGuard::mayManageRole($actorRole, $actorPermissions, $id, $targetPermissions)) { $this->managerTheme->alertAndQuit('error_no_privileges'); diff --git a/core/src/Controllers/Users/EditOrNewUser.php b/core/src/Controllers/Users/EditOrNewUser.php index 2d19c269d2..5e35bd0087 100644 --- a/core/src/Controllers/Users/EditOrNewUser.php +++ b/core/src/Controllers/Users/EditOrNewUser.php @@ -154,8 +154,7 @@ public function process(): bool \EvolutionCMS\Models\Permissions::query()->pluck('key')->all(), static fn ($key) => (bool) EvolutionCMS()->hasPermission($key) )); - $newRolePermissions = \EvolutionCMS\Models\RolePermissions::query() - ->where('role_id', $newRole)->pluck('permission')->all(); + $newRolePermissions = \EvolutionCMS\Support\RoleGuard::reachOf($newRole); if (isset($userData['role']) && $userData['role'] != $user->attributes->role diff --git a/core/src/Support/RoleGuard.php b/core/src/Support/RoleGuard.php index 4ce12b4bcf..2c759ae548 100644 --- a/core/src/Support/RoleGuard.php +++ b/core/src/Support/RoleGuard.php @@ -1,5 +1,8 @@ where('role_id', $roleId) + ->whereIn('permission', Permissions::query()->where('disabled', 0)->select('key')) + ->pluck('permission') + ->all(); + } + /** * The permission keys a manager may actually store on a role: the requested keys it also holds, * plus the always-on baseline keys. An administrator keeps everything requested. diff --git a/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php new file mode 100644 index 0000000000..963fdbf6a6 --- /dev/null +++ b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php @@ -0,0 +1,185 @@ +permissions, true) ? 1 : 0; + } +} + +class RoleReachThemeStub +{ + public ?string $quitMessage = null; + + public function __construct(public RoleReachCoreStub $core) + { + } + + public function getCore(): RoleReachCoreStub + { + return $this->core; + } + + public function alertAndQuit($message, $lexicon = true, $url = ''): void + { + $this->quitMessage = (string) $message; + throw new RuntimeException('alertAndQuit:' . $message); + } +} + +/** A role manager (role 2) holding every real permission of the default Editor role 3, and no more. */ +function roleReachController(array $permissions = ['save_role', 'delete_role', 'edit_document', 'save_document']): UserRoleController +{ + $_SESSION['mgrRole'] = 2; + $controller = (new ReflectionClass(UserRoleController::class))->newInstanceWithoutConstructor(); + $property = new ReflectionProperty(UserRoleController::class, 'managerTheme'); + $property->setAccessible(true); + $property->setValue($controller, new RoleReachThemeStub(new RoleReachCoreStub($permissions))); + + return $controller; +} + +/** guardRoleReach(), which role save (a=35/36) runs before anything else */ +function roleReachMaySave(UserRoleController $controller, int $role): bool +{ + try { + (new ReflectionMethod(UserRoleController::class, 'guardRoleReach'))->invoke($controller, $role); + + return true; + } catch (RuntimeException $e) { + return false; + } +} + +function roleReachDelete(UserRoleController $controller, int $role): bool +{ + $_GET['action'] = $_REQUEST['action'] = 'delete'; + $_GET['id'] = $_REQUEST['id'] = $role; + try { + // header() after the delete only warns in the CLI + @$controller->process(); + } catch (RuntimeException $e) { + } + + return !Capsule::table('user_roles')->where('id', $role)->exists(); +} + +/** The check EditOrNewUser makes before it assigns $role to a user. */ +function roleReachMayAssign(array $permissions, int $role): bool +{ + return RoleGuard::mayManageRole(2, $permissions, $role, RoleGuard::reachOf($role)); +} + +beforeEach(function () { + $this->capsule = CacheRefreshHarness::boot(':memory:'); + $schema = $this->capsule->getConnection()->getSchemaBuilder(); + $schema->create('user_roles', function (Blueprint $t) { + $t->increments('id'); + $t->string('name')->default(''); + $t->text('description')->nullable(); + }); + $schema->create('role_permissions', function (Blueprint $t) { + $t->increments('id'); + $t->unsignedInteger('role_id')->default(0); + $t->string('permission')->default(''); + $t->timestamps(); + }); + $schema->create('permissions', function (Blueprint $t) { + $t->increments('id'); + $t->string('key')->default(''); + $t->unsignedTinyInteger('disabled')->default(0); + }); + foreach (['user_attributes' => 'role', 'site_module_roles' => 'role', 'user_role_vars' => 'roleid'] as $table => $column) { + $schema->create($table, function (Blueprint $t) use ($column) { + $t->increments('id'); + $t->unsignedInteger($column)->default(0); + }); + } + + // as an upgraded site has them: action_ok is still defined (always-on), role_actionok never was + foreach (['save_role' => 0, 'delete_role' => 0, 'edit_document' => 0, 'save_document' => 0, 'settings' => 0, + 'action_ok' => 1, 'error_dialog' => 1] as $key => $disabled) { + $this->capsule->table('permissions')->insert(['key' => $key, 'disabled' => $disabled]); + } + $this->capsule->table('user_roles')->insert(['id' => 3, 'name' => 'Editor', 'description' => '']); + foreach (['edit_document', 'save_document', 'role_actionok', 'action_ok', 'error_dialog'] as $key) { + $this->capsule->table('role_permissions')->insert(['role_id' => 3, 'permission' => $key]); + } +}); + +afterEach(function () { + Capsule::connection()->disconnect(); + foreach (['action', 'id'] as $k) { + unset($_GET[$k], $_REQUEST[$k]); + } + unset($_SESSION['mgrRole']); +}); + +it('counts only the grants of defined, optional permissions', function () { + expect(RoleGuard::reachOf(3))->toEqualCanonicalizing(['edit_document', 'save_document']) + ->and(RoleGuard::reachOf(99))->toBe([]); +}); + +it('lets a role manager with every real permission of a legacy default role save, assign and delete it', function () { + $permissions = ['save_role', 'delete_role', 'edit_document', 'save_document']; + + expect(roleReachMaySave(roleReachController($permissions), 3))->toBeTrue() + ->and(roleReachMayAssign($permissions, 3))->toBeTrue() + ->and(roleReachDelete(roleReachController($permissions), 3))->toBeTrue(); +}); + +it('still refuses the legacy role when the actor lacks one of its real permissions', function (array $permissions, ?string $extraGrant) { + if ($extraGrant !== null) { + $this->capsule->table('role_permissions')->insert(['role_id' => 3, 'permission' => $extraGrant]); + } + + expect(roleReachMaySave(roleReachController($permissions), 3))->toBeFalse() + ->and(roleReachMayAssign($permissions, 3))->toBeFalse() + ->and(roleReachDelete(roleReachController($permissions), 3))->toBeFalse() + ->and($this->capsule->table('role_permissions')->where('role_id', 3)->count())->toBeGreaterThan(0); +})->with([ + 'a permission of the role' => [['save_role', 'delete_role', 'edit_document'], null], + 'a stronger permission on the role' => [['save_role', 'delete_role', 'edit_document', 'save_document'], 'settings'], +]); + +it('keeps the administrator role out of reach', function () { + $permissions = ['save_role', 'delete_role', 'edit_document', 'save_document', 'settings']; + + expect(roleReachMayAssign($permissions, RoleGuard::ADMIN_ROLE))->toBeFalse() + ->and(roleReachMaySave(roleReachController($permissions), RoleGuard::ADMIN_ROLE))->toBeFalse(); +}); + +test('both reach checks read the target grants through reachOf()', function () { + $src = dirname(__DIR__, 3) . '/src/Controllers/'; + + expect((string) file_get_contents($src . 'UserRoles/UserRole.php'))->toContain('$targetPermissions = RoleGuard::reachOf($id);') + ->and((string) file_get_contents($src . 'Users/EditOrNewUser.php')) + ->toContain('$newRolePermissions = \EvolutionCMS\Support\RoleGuard::reachOf($newRole);'); +});