From a999905456a34dff016480e060ef9acf64b9660b Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 28 Sep 2026 03:51:37 +0200 Subject: [PATCH 1/2] fix(core): file access check in file browser and file manager, installer extension case fix --- README.md | 18 +- core/functions/actions/files.php | 227 ++++++++++++++--- .../Install/InstallerExtensionCheckTest.php | 37 +++ .../FileManagerPathContainmentTest.php | 240 ++++++++++++++++++ .../FileManagerWritePathChecksTest.php | 157 ++++++++++++ .../MediaBrowserSymlinkContainmentTest.php | 163 ++++++++++++ install/src/controllers/summary.php | 4 +- install/src/functions.php | 18 ++ manager/actions/files.dynamic.php | 97 +------ manager/media/browser/mcpuk/core/browser.php | 67 ++++- 10 files changed, 892 insertions(+), 136 deletions(-) create mode 100644 core/tests/Unit/Install/InstallerExtensionCheckTest.php create mode 100644 core/tests/Unit/Security/FileManagerPathContainmentTest.php create mode 100644 core/tests/Unit/Security/FileManagerWritePathChecksTest.php create mode 100644 core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php diff --git a/README.md b/README.md index 09bdb11be4..62dc72c9a4 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,25 @@ ## History -Initially inspired by **Etomite 0.6**, then it has been **MODX Evolution 0.7 - 1.0.8** is an ongoing project written by *Raymond Irving* and a core team of contributors **MODX**, and now its **Evolution CMS** maintained by *Dmytro Lukianenko*, *Serhii Korneliuk* and a core team of contributors at the **Evolution CMS Project**. +Initially inspired by **Etomite 0.6**, the project became **MODX Evolution**, the original MODX codebase developed by *Raymond Irving*, the MODX team, and its contributors. + +In its later years under MODX, development and releases were already being led by the team that subsequently formed the **Evolution CMS Project**, +including *Dmytro Lukianenko*. In 2016, MODX transferred stewardship of Evolution to that team, and in April 2017 formally recognized **Evolution CMS** as an independent project under its existing leadership. + +Development has continued in `evolution-cms/evolution` as the direct continuation of MODX Evolution, maintained by *Dmytro Lukianenko*, *Serhii Korneliuk*, +and the Evolution CMS core team and contributors. The former `modxcms/evolution` repository was later archived on Mar 9, 2021 and is now read-only. + +Other repositories derived from Evolution CMS are independent downstream forks or distributions and do not represent the project lineage established by the MODX Evolution team. ## License -**Evolution CMS** is distributed under the **GPL license** and is now run by a professional team of developers from all over the world. Visit the Forums for more information. +**Evolution CMS** is distributed under the **GPL v3 or later** license and is maintained by a professional international team of developers. See the [Evolution CMS Team](https://evo.im/team.html) for more information. + +### Name clarification + +**Evolution CMS**, formerly **MODX Evolution**, is a web content management system originating from the MODX project. + +It is not affiliated with Yamaha Corporation, Yamaha MODX/MODX+ synthesizers, or any Yamaha sound libraries, expansion packs, or products using the name “Evolution”. ## Features diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php index 48743df9a0..f66505d1d0 100644 --- a/core/functions/actions/files.php +++ b/core/functions/actions/files.php @@ -160,6 +160,181 @@ function fileManagerResolvePath($filemanagerPath, $requestedPath) } } +if(!function_exists('fileManagerProtectedPaths')) { + /** + * Folders the file manager must not enter or change: always the manager and the backups, + * and each element folder unless the user may edit that element type anyway. + * + * @return string[] canonical absolute paths, no trailing slash + */ + function fileManagerProtectedPaths() + { + $evo = evolutionCMS(); + $paths = [ + EVO_MANAGER_PATH, + EVO_BASE_PATH . 'temp/backup', + EVO_BASE_PATH . 'assets/backup', + ]; + $byPermission = [ + 'save_plugin' => ['assets/plugins'], + 'save_snippet' => ['assets/snippets'], + 'save_template' => ['assets/templates'], + 'save_module' => ['assets/modules'], + 'empty_cache' => ['assets/cache'], + 'import_static' => ['temp/import', 'assets/import'], + 'export_static' => ['temp/export', 'assets/export'], + ]; + foreach ($byPermission as $permission => $folders) { + if (!$evo->hasPermission($permission)) { + foreach ($folders as $folder) { + $paths[] = EVO_BASE_PATH . $folder; + } + } + } + + return array_map( + static fn ($path) => rtrim(str_replace('\\', '/', realpath($path) ?: $path), '/'), + $paths + ); + } +} + +if(!function_exists('fileManagerPathIsProtected')) { + /** + * Whether $path is a protected folder or lies inside one. + * + * @param string $path canonical absolute path + * @param string[] $protectedPaths + */ + function fileManagerPathIsProtected($path, array $protectedPaths) + { + foreach ($protectedPaths as $protectedPath) { + if (FileManagerAccess::isWithin($protectedPath, $path)) { + return true; + } + } + + return false; + } +} + +if(!function_exists('fileManagerPathTouchesProtected')) { + /** + * Whether removing or renaming $path would affect a protected folder: it lies inside one, + * or one lies inside it. + * + * @param string $path canonical absolute path + * @param string[] $protectedPaths + */ + function fileManagerPathTouchesProtected($path, array $protectedPaths) + { + foreach ($protectedPaths as $protectedPath) { + if (FileManagerAccess::isWithin($protectedPath, $path) || FileManagerAccess::isWithin($path, $protectedPath)) { + return true; + } + } + + return false; + } +} + +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. + * + * @param string $root canonical absolute path + * @param string $target absolute path below $root + */ + function fileManagerIsSafeWriteTarget($root, $target) + { + $root = rtrim(str_replace('\\', '/', $root), '/'); + $target = rtrim(str_replace('\\', '/', $target), '/'); + if ($target === $root || !FileManagerAccess::isWithin($root, $target)) { + return false; + } + + $current = $root; + foreach (explode('/', substr($target, strlen($root) + 1)) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + return false; + } + $current .= '/' . $segment; + if (is_link($current)) { + return false; + } + if (!file_exists($current)) { + // everything below is created by this write, so none of it can be a link + break; + } + } + + return true; + } +} + +if(!function_exists('fileManagerExtractZip')) { + /** + * Extracts $file into $path, skipping any entry that would land outside it, go through a + * symlink, or reach a protected folder. + * + * @param string $file + * @param string $path + * @param string[] $protectedPaths + * @param int $dirMode + * @return bool false when the archive cannot be opened + */ + function fileManagerExtractZip($file, $path, array $protectedPaths = [], $dirMode = 0777) + { + $root = realpath($path); + if ($root === false) { + return false; + } + $root = rtrim(str_replace('\\', '/', $root), '/'); + + $zip = new ZipArchive(); + if ($zip->open($file) !== true) { + return false; + } + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $filename = str_replace('\\', '/', $stat['name']); + if (substr($filename, 0, 1) == '/' || strpos($filename, ':') !== false) { + continue; // skip absolute paths + } + // only a whole ".." segment climbs up: "just-stop..png" is an ordinary file name + // (Windows hides the extension, so "just-stop." easily becomes one) + $segments = array_values(array_filter( + explode('/', $filename), + static fn ($segment) => $segment !== '' && $segment !== '.' + )); + if ($segments === [] || in_array('..', $segments, true)) { + continue; + } + $isDir = substr($filename, -1) == '/'; + $target = $root . '/' . implode('/', $segments); + if (!fileManagerIsSafeWriteTarget($root, $target) || fileManagerPathIsProtected($target, $protectedPaths)) { + continue; + } + if ($isDir) { + if (!is_dir($target)) { + mkdir($target, $dirMode, true); + } + continue; + } + $dirname = dirname($target); + if (!is_dir($dirname)) { + mkdir($dirname, $dirMode, true); + } + file_put_contents($target, $zip->getFromIndex($i)); + } + $zip->close(); + + return true; + } +} + if(!function_exists('fileManagerSubtreeRestrictionMap')) { /** * @param string $relativePath relative to the manager's root @@ -619,41 +794,10 @@ function unzip($file, $path) // end mod $old_umask = umask(0); - $path = rtrim(str_replace('\\', '/', realpath($path)), '/\\'); // No trailing slash - - $zip = new ZipArchive(); - if ($zip->open($file) !== true) { - umask($old_umask); - return false; - } - for ($i = 0; $i < $zip->numFiles; $i++) { - $stat = $zip->statIndex($i); - $filename = str_replace('\\', '/', $stat['name']); - if (substr($filename, 0, 1) == '/' || strpos($filename, '..') !== false || - strpos($filename, ':') !== false) { - continue; // skip malicious paths - } - $target = $path . '/' . $filename; - // Additional check to ensure target is within path - $target_dir = rtrim(str_replace('\\', '/', realpath(dirname($target)) ?: dirname($target)), '/\\'); - if (!FileManagerAccess::isWithin($path, $target_dir)) { - continue; - } - if (substr($filename, -1) == '/') { - if (!is_dir($target)) { - mkdir($target, $newfolderaccessmode ?: 0777, true); - } - } else { - $dirname = dirname($target); - if (!is_dir($dirname)) { - mkdir($dirname, $newfolderaccessmode ?: 0777, true); - } - file_put_contents($target, $zip->getFromIndex($i)); - } - } - $zip->close(); + $result = fileManagerExtractZip($file, $path, fileManagerProtectedPaths(), $newfolderaccessmode ?: 0777); umask($old_umask); - return true; + + return $result; } } @@ -683,6 +827,8 @@ function rrmdir($dir) */ function fileupload() { + global $_lang, $uploadablefiles; + $modx = evolutionCMS(); $filemanager_path = rtrim(str_replace('\\', '/', realpath(evolutionCMS() ->getConfig('filemanager_path', EVO_BASE_PATH))), '/'); // Canonicalize base path @@ -694,11 +840,12 @@ function fileupload() return '

Invalid path.

'; } $dirRel = ltrim(substr($startpath, strlen($filemanager_path)), '/'); - if (!fileManagerIsAccessible($dirRel) || !is_writable($startpath)) { + if (!fileManagerIsAccessible($dirRel) + || fileManagerPathIsProtected($startpath, fileManagerProtectedPaths()) + || !is_writable($startpath)) { return '

' . $_lang['files_access_denied'] . '

'; } $new_file_permissions = octdec(evolutionCMS()->getConfig('new_file_permissions', '0666')); - global $_lang, $uploadablefiles; $msg = ''; $dirGroupIds = []; if (evolutionCMS()->getConfig('use_udperms')) { @@ -824,7 +971,9 @@ function textsave() return 'Invalid path.

'; } $fileRel = ltrim(substr($filename, strlen($filemanager_path)), '/'); - if (!fileManagerCanModifyExistingPath($fileRel) || !is_writable($filename)) { + if (!fileManagerCanModifyExistingPath($fileRel) + || fileManagerPathIsProtected($filename, fileManagerProtectedPaths()) + || !is_writable($filename)) { return '' . $_lang['files_access_denied'] . '

'; } $content = $_POST['content']; @@ -859,7 +1008,9 @@ function delete_file() return 'Invalid path.

'; } $fileRel = ltrim(substr($file, strlen($filemanager_path)), '/'); - if (!fileManagerCanModifyExistingPath($fileRel) || !is_writable($file)) { + if (!fileManagerCanModifyExistingPath($fileRel) + || fileManagerPathIsProtected($file, fileManagerProtectedPaths()) + || !is_writable($file)) { return '' . $_lang['files_access_denied'] . '

'; } $msg = sprintf($_lang['deleting_file'], str_replace('\\', '/', $file)); diff --git a/core/tests/Unit/Install/InstallerExtensionCheckTest.php b/core/tests/Unit/Install/InstallerExtensionCheckTest.php new file mode 100644 index 0000000000..b10e54d4fc --- /dev/null +++ b/core/tests/Unit/Install/InstallerExtensionCheckTest.php @@ -0,0 +1,37 @@ + false, 'spl' => true])); + } + + public function testSimplexmlIsFoundWhenLoaded(): void + { + if (!extension_loaded('SimpleXML')) { + self::markTestSkipped('SimpleXML is not loaded'); + } + + self::assertSame([], missingInstallExtensions(['simplexml' => false])); + } + + public function testMissingExtensionsKeepTheirMandatoryFlag(): void + { + $missing = missingInstallExtensions([ + 'json' => true, + 'evo_missing_mandatory' => true, + 'evo_missing_optional' => false, + ]); + + self::assertSame(['evo_missing_mandatory' => true, 'evo_missing_optional' => false], $missing); + } +} diff --git a/core/tests/Unit/Security/FileManagerPathContainmentTest.php b/core/tests/Unit/Security/FileManagerPathContainmentTest.php new file mode 100644 index 0000000000..4b52dc426a --- /dev/null +++ b/core/tests/Unit/Security/FileManagerPathContainmentTest.php @@ -0,0 +1,240 @@ +markTestSkipped('symlinks are not available here'); + } +} + +function fmContainmentZip(string $path, array $entries): void +{ + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); + foreach ($entries as $name => $content) { + if (substr($name, -1) === '/') { + $zip->addEmptyDir(rtrim($name, '/')); + } else { + $zip->addFromString($name, $content); + } + } + $zip->close(); +} + +beforeEach(function () { + $this->tmp = fmContainmentTempDir(); +}); + +afterEach(function () { + fmContainmentRemove($this->tmp); +}); + +it('treats only the protected folder and what is inside it as protected', function () { + $protected = ['/var/www/site/assets/plugins']; + + expect(fileManagerPathIsProtected('/var/www/site/assets/plugins', $protected))->toBeTrue() + ->and(fileManagerPathIsProtected('/var/www/site/assets/plugins/tinymce/plugin.php', $protected))->toBeTrue() + ->and(fileManagerPathIsProtected('/var/www/site/assets/plugins-old/plugin.php', $protected))->toBeFalse() + ->and(fileManagerPathIsProtected('/var/www/site/assets', $protected))->toBeFalse(); +}); + +it('counts a folder that contains a protected folder as touching it', function () { + $protected = ['/var/www/site/assets/plugins']; + + expect(fileManagerPathTouchesProtected('/var/www/site/assets', $protected))->toBeTrue() + ->and(fileManagerPathTouchesProtected('/var/www/site/assets/plugins/x', $protected))->toBeTrue() + ->and(fileManagerPathTouchesProtected('/var/www/site/assets/images', $protected))->toBeFalse(); +}); + +it('accepts write targets below the root and rejects ones that leave it', function () { + mkdir($this->tmp . '/root/sub', 0777, true); + $root = $this->tmp . '/root'; + + expect(fileManagerIsSafeWriteTarget($root, $root . '/sub/new/file.txt'))->toBeTrue() + ->and(fileManagerIsSafeWriteTarget($root, $root . '/just-stop..png'))->toBeTrue() + ->and(fileManagerIsSafeWriteTarget($root, $root))->toBeFalse() + ->and(fileManagerIsSafeWriteTarget($root, $root . '/../outside.txt'))->toBeFalse() + ->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); + mkdir($this->tmp . '/outside', 0777, true); + fmContainmentSymlink($this->tmp . '/outside', $this->tmp . '/root/link'); + + expect(fileManagerIsSafeWriteTarget($this->tmp . '/root', $this->tmp . '/root/link/new/file.txt'))->toBeFalse(); +}); + +it('rejects a write target that is itself a symlink', function () { + mkdir($this->tmp . '/root', 0777, true); + file_put_contents($this->tmp . '/victim.txt', 'original'); + fmContainmentSymlink($this->tmp . '/victim.txt', $this->tmp . '/root/file.txt'); + + expect(fileManagerIsSafeWriteTarget($this->tmp . '/root', $this->tmp . '/root/file.txt'))->toBeFalse(); +}); + +it('extracts ordinary entries, including names with a double dot before the extension', function () { + mkdir($this->tmp . '/dest', 0777, true); + fmContainmentZip($this->tmp . '/a.zip', [ + 'just-stop..png' => 'png', + 'folder/' => '', + 'folder/nested/note.txt' => 'note', + ]); + + expect(fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/dest'))->toBeTrue() + ->and(file_get_contents($this->tmp . '/dest/just-stop..png'))->toBe('png') + ->and(is_dir($this->tmp . '/dest/folder'))->toBeTrue() + ->and(file_get_contents($this->tmp . '/dest/folder/nested/note.txt'))->toBe('note'); +}); + +it('decides each zip entry by its path segments, not by the characters in its name', function (string $entry, ?string $expected) { + mkdir($this->tmp . '/dest', 0777, true); + fmContainmentZip($this->tmp . '/a.zip', [$entry => 'data']); + + fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/dest'); + + $written = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->tmp, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $item) { + $path = substr(str_replace('\\', '/', $item->getPathname()), strlen($this->tmp) + 1); + if ($path !== 'a.zip') { + $written[] = $path; + } + } + + expect($written)->toBe($expected === null ? [] : [$expected]); +})->with([ + 'double dot before the extension' => ['just-stop..png', 'dest/just-stop..png'], + 'double dot inside a folder' => ['photos/just-stop..png', 'dest/photos/just-stop..png'], + 'name starting with two dots' => ['..env.txt', 'dest/..env.txt'], + 'folder with a double dot' => ['v1..2/readme.txt', 'dest/v1..2/readme.txt'], + 'parent segment' => ['../escaped.txt', null], + 'nested parent segment' => ['a/../../escaped.txt', null], + 'backslash parent segment' => ['..\\escaped.txt', null], + 'absolute path' => ['/escaped.txt', null], + 'drive letter' => ['C:/escaped.txt', null], + 'current folder prefix' => ['./here.txt', 'dest/here.txt'], + 'doubled slash' => ['photos//pic.png', 'dest/photos/pic.png'], +]); + +it('extracts normally when the site root is reached through a symlink, like public_html', function () { + mkdir($this->tmp . '/srv/evo/assets/files', 0777, true); + fmContainmentSymlink($this->tmp . '/srv/evo', $this->tmp . '/public_html'); + fmContainmentZip($this->tmp . '/a.zip', ['just-stop..png' => 'png', 'photos/pic.png' => 'png']); + + expect(fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/public_html/assets/files'))->toBeTrue() + ->and(file_get_contents($this->tmp . '/srv/evo/assets/files/just-stop..png'))->toBe('png') + ->and(file_get_contents($this->tmp . '/srv/evo/assets/files/photos/pic.png'))->toBe('png'); +}); + +it('matches protected folders when the site root is reached through a symlink', function () { + mkdir($this->tmp . '/srv/evo/assets/plugins', 0777, true); + fmContainmentSymlink($this->tmp . '/srv/evo', $this->tmp . '/public_html'); + // both sides are canonical in the file manager: protected paths and $startpath go through realpath() + $protected = [str_replace('\\', '/', realpath($this->tmp . '/public_html/assets/plugins'))]; + $startpath = str_replace('\\', '/', realpath($this->tmp . '/public_html/assets/plugins')); + fmContainmentZip($this->tmp . '/a.zip', ['plugins/evil.php' => 'x', 'ok.txt' => 'ok']); + + fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/public_html/assets', $protected); + + expect(fileManagerPathIsProtected($startpath, $protected))->toBeTrue() + ->and(file_exists($this->tmp . '/srv/evo/assets/plugins/evil.php'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/srv/evo/assets/ok.txt'))->toBe('ok'); +}); + +it('skips entries that climb out of the destination', function () { + mkdir($this->tmp . '/dest', 0777, true); + fmContainmentZip($this->tmp . '/a.zip', [ + '../escaped.txt' => 'x', + 'folder/../../escaped2.txt' => 'x', + 'ok.txt' => 'ok', + ]); + + fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/dest'); + + expect(file_exists($this->tmp . '/escaped.txt'))->toBeFalse() + ->and(file_exists($this->tmp . '/escaped2.txt'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/dest/ok.txt'))->toBe('ok'); +}); + +it('skips entries that land in a protected folder below the destination', function () { + mkdir($this->tmp . '/dest/plugins', 0777, true); + fmContainmentZip($this->tmp . '/a.zip', [ + 'plugins/evil.php' => ' '', + 'images/ok.png' => 'png', + ]); + + fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/dest', [$this->tmp . '/dest/plugins']); + + expect(file_exists($this->tmp . '/dest/plugins/evil.php'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/dest/images/ok.png'))->toBe('png'); +}); + +it('does not follow a symlinked folder or 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'); + fmContainmentSymlink($this->tmp . '/victim.txt', $this->tmp . '/dest/file.txt'); + fmContainmentZip($this->tmp . '/a.zip', [ + 'link/new/escaped.txt' => 'x', + 'file.txt' => 'overwritten', + ]); + + fileManagerExtractZip($this->tmp . '/a.zip', $this->tmp . '/dest'); + + expect(file_exists($this->tmp . '/outside/new/escaped.txt'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/victim.txt'))->toBe('original'); +}); + +it('checks protected folders in every file manager write before anything runs', function () { + $functions = file_get_contents(dirname(__DIR__, 3) . '/functions/actions/files.php'); + $dynamic = file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php'); + + foreach (['function fileupload', 'function textsave', 'function delete_file'] as $function) { + $body = substr($functions, strpos($functions, $function), 2500); + expect($body)->toContain('fileManagerProtectedPaths()'); + } + + $gate = strpos($dynamic, 'if (fileManagerPathIsProtected($startpath, $protected_path)) {'); + expect($gate)->not->toBeFalse() + ->and($gate)->toBeLessThan(strpos($dynamic, 'echo textsave();')) + ->and($gate)->toBeLessThan(strpos($dynamic, '$information = fileupload();')) + ->and($dynamic)->toContain('$protected_path = fileManagerProtectedPaths();') + ->and($dynamic)->toContain('fileManagerPathTouchesProtected($folder, $protected_path)') + ->and($dynamic)->toContain('fileManagerPathTouchesProtected($dirname, $protected_path)') + ->and($dynamic)->toContain('fileManagerExtractZip($zipTarget[\'path\'], $startpath, $protected_path)') + ->and($dynamic)->not->toContain('function safe_unzip'); +}); diff --git a/core/tests/Unit/Security/FileManagerWritePathChecksTest.php b/core/tests/Unit/Security/FileManagerWritePathChecksTest.php new file mode 100644 index 0000000000..9acc79cc2f --- /dev/null +++ b/core/tests/Unit/Security/FileManagerWritePathChecksTest.php @@ -0,0 +1,157 @@ + EVO_BASE_PATH, "use_udperms" => 0,' + . ' "upload_files" => "txt,zip", "upload_images" => "png", "upload_media" => ""][$key] ?? $default;' + . ' }' + . ' public function hasPermission($permission) { return in_array($permission, $this->permissions, true); }' + . ' public function invokeEvent(...$args) { return []; }' + . ' }' + // the file manager itself is only reachable with file_manager + . ' $GLOBALS["fmStub"] = new FmStubEvo(' . var_export(array_merge(['file_manager'], $permissions), true) . ');' + . ' function evolutionCMS() { return $GLOBALS["fmStub"]; }' + . ' function logFileChange($type, $filename) {}' + // no file groups here: keeps delete_file() away from the database + . ' function fileManagerAclKey($relativePath) { return null; }' + . ' $_lang = ["files_access_denied" => "ACCESS_DENIED", "file_saved" => "SAVED", "file_not_saved" => "NOT_SAVED",' + . ' "deleting_file" => "Deleting %s: ", "file_deleted" => "DELETED", "file_not_deleted" => "NOT_DELETED"];' + . ' $_POST = ' . var_export($request, true) . ';' + . ' $_REQUEST = $_POST;' + . ' $_FILES = ["userfile" => ["name" => [], "tmp_name" => [], "error" => [], "type" => []]];' + . ' require ' . var_export($core . '/functions/actions/files.php', true) . ';' + . ' echo strip_tags(' . $function . '());'; + + $tmp = tempnam(sys_get_temp_dir(), 'evo-fm-'); + file_put_contents($tmp, $script); + try { + $output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($tmp) . ' 2>&1'); + } finally { + unlink($tmp); + } + + return (string) $output; +} + +beforeEach(function () { + $base = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-fm-write-' . bin2hex(random_bytes(6)); + foreach (['site/assets/plugins/demo', 'site/assets/images', 'site/manager', 'site-old'] as $dir) { + mkdir($base . '/' . $dir, 0777, true); + } + $base = str_replace('\\', '/', realpath($base)); + file_put_contents($base . '/site/notes..txt', 'original'); + file_put_contents($base . '/site/assets/plugins/demo/plugin.php', 'original'); + file_put_contents($base . '/site/manager/index.php', 'original'); + file_put_contents($base . '/site-old/secret.txt', 'original'); + $this->base = $base; + $this->site = $base . '/site'; +}); + +afterEach(function () { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($this->base); +}); + +it('saves an ordinary file whose name has a double dot before the extension', function () { + $output = runFileManagerWrite($this->site, 'textsave', ['path' => 'notes..txt', 'content' => 'changed']); + + expect($output)->toContain('SAVED')->not->toContain('NOT_SAVED') + ->and(file_get_contents($this->site . '/notes..txt'))->toBe('changed'); +}); + +it('refuses to save into a sibling folder that shares the root name prefix', function () { + $output = runFileManagerWrite($this->site, 'textsave', ['path' => '../site-old/secret.txt', 'content' => 'changed']); + + expect($output)->toContain('Invalid path') + ->and(file_get_contents($this->base . '/site-old/secret.txt'))->toBe('original'); +}); + +it('refuses to save a plugin file without save_plugin', function () { + $output = runFileManagerWrite($this->site, 'textsave', ['path' => 'assets/plugins/demo/plugin.php', 'content' => 'changed']); + + expect($output)->toContain('ACCESS_DENIED') + ->and(file_get_contents($this->site . '/assets/plugins/demo/plugin.php'))->toBe('original'); +}); + +it('saves a plugin file for a user with save_plugin', function () { + $output = runFileManagerWrite( + $this->site, + 'textsave', + ['path' => 'assets/plugins/demo/plugin.php', 'content' => 'changed'], + ['save_plugin'] + ); + + expect($output)->toContain('SAVED') + ->and(file_get_contents($this->site . '/assets/plugins/demo/plugin.php'))->toBe('changed'); +}); + +it('refuses to save manager files whatever the permissions', function () { + $output = runFileManagerWrite( + $this->site, + 'textsave', + ['path' => 'manager/index.php', 'content' => 'changed'], + ['save_plugin', 'save_snippet', 'save_template', 'save_module', 'empty_cache', 'import_static', 'export_static'] + ); + + expect($output)->toContain('ACCESS_DENIED') + ->and(file_get_contents($this->site . '/manager/index.php'))->toBe('original'); +}); + +it('refuses to delete a plugin file without save_plugin', function () { + $output = runFileManagerWrite($this->site, 'delete_file', ['path' => 'assets/plugins/demo/plugin.php']); + + expect($output)->toContain('ACCESS_DENIED') + ->and(is_file($this->site . '/assets/plugins/demo/plugin.php'))->toBeTrue(); +}); + +it('refuses to delete from a sibling folder that shares the root name prefix', function () { + $output = runFileManagerWrite($this->site, 'delete_file', ['path' => '../site-old/secret.txt']); + + expect($output)->toContain('Invalid path') + ->and(is_file($this->base . '/site-old/secret.txt'))->toBeTrue(); +}); + +it('deletes an ordinary file whose name has a double dot before the extension', function () { + $output = runFileManagerWrite($this->site, 'delete_file', ['path' => 'notes..txt']); + + expect($output)->toContain('DELETED')->not->toContain('NOT_DELETED') + ->and(is_file($this->site . '/notes..txt'))->toBeFalse(); +}); + +it('refuses uploads into a protected folder, with a readable message', function () { + $output = runFileManagerWrite($this->site, 'fileupload', ['path' => 'assets/plugins/demo']); + + expect($output)->toContain('ACCESS_DENIED'); +}); + +it('refuses uploads into a sibling folder that shares the root name prefix', function () { + $output = runFileManagerWrite($this->site, 'fileupload', ['path' => '../site-old']); + + expect($output)->toContain('Invalid path'); +}); + +it('accepts uploads into an ordinary folder', function () { + $output = runFileManagerWrite($this->site, 'fileupload', ['path' => 'assets/images']); + + expect($output)->not->toContain('ACCESS_DENIED')->not->toContain('Invalid path'); +}); diff --git a/core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php b/core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php new file mode 100644 index 0000000000..64f5d6577e --- /dev/null +++ b/core/tests/Unit/Security/MediaBrowserSymlinkContainmentTest.php @@ -0,0 +1,163 @@ +aclRootForTest, $absPath); + } +} + +function mediaBrowserForTypeDir(string $typeDir): browser +{ + $browser = (new ReflectionClass(MediaBrowserContainmentHarness::class))->newInstanceWithoutConstructor(); + // as on a site: file groups are keyed from the site root, one level above the upload folder + $browser->aclRootForTest = dirname($typeDir, 2); + $property = new ReflectionProperty(uploader::class, 'typeDir'); + $property->setAccessible(true); + $property->setValue($browser, $typeDir); + + return $browser; +} + +function mediaBrowserIsInside(browser $browser, string $path): bool +{ + $method = new ReflectionMethod(browser::class, 'isInsideTypeDir'); + $method->setAccessible(true); + + return $method->invoke($browser, $path); +} + +beforeEach(function () { + $tmp = sys_get_temp_dir() . '/evo-kcf-' . bin2hex(random_bytes(6)); + mkdir($tmp . '/images/team', 0777, true); + mkdir($tmp . '/images-old', 0777, true); + mkdir($tmp . '/outside', 0777, true); + file_put_contents($tmp . '/outside/secret.txt', 'secret'); + $this->tmp = str_replace('\\', '/', realpath($tmp)); + $this->browser = mediaBrowserForTypeDir($this->tmp . '/images'); +}); + +afterEach(function () { + foreach (['images/link', 'images/secret.txt', 'images/dangling'] as $link) { + if (is_link($this->tmp . '/' . $link)) { + @unlink($this->tmp . '/' . $link) || @rmdir($this->tmp . '/' . $link); + } + } + @unlink($this->tmp . '/outside/secret.txt'); + foreach (['images/team', 'images', 'images-old', 'outside', ''] as $dir) { + @rmdir($this->tmp . '/' . $dir); + } +}); + +function mediaBrowserSymlink(string $target, string $link): void +{ + if (!function_exists('symlink') || !@symlink($target, $link)) { + test()->markTestSkipped('symlinks are not available here'); + } +} + +it('accepts folders and new entries inside the type folder', function () { + expect(mediaBrowserIsInside($this->browser, $this->tmp . '/images'))->toBeTrue() + ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/team'))->toBeTrue() + ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/team/new.png'))->toBeTrue() + ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/team/just-stop..png'))->toBeTrue(); +}); + +it('rejects a sibling folder that only shares the name prefix', function () { + expect(mediaBrowserIsInside($this->browser, $this->tmp . '/images-old'))->toBeFalse() + ->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'); + + 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(); +}); + +it('rejects a symlinked file and a dangling symlink', function () { + mediaBrowserSymlink($this->tmp . '/outside/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() + ->and(mediaBrowserIsInside($this->browser, $this->tmp . '/images/dangling'))->toBeFalse(); +}); + +it('works when the site root is reached through a symlink, like public_html', function () { + mediaBrowserSymlink($this->tmp, $this->tmp . '-public_html'); + try { + $browser = mediaBrowserForTypeDir($this->tmp . '-public_html/images'); + + expect(mediaBrowserIsInside($browser, $this->tmp . '-public_html/images/team'))->toBeTrue() + ->and(mediaBrowserIsInside($browser, $this->tmp . '/images/team'))->toBeTrue() + ->and(mediaBrowserIsInside($browser, $this->tmp . '-public_html/images/team/new.png'))->toBeTrue() + ->and(mediaBrowserIsInside($browser, $this->tmp . '-public_html/outside/secret.txt'))->toBeFalse(); + } finally { + @unlink($this->tmp . '-public_html') || @rmdir($this->tmp . '-public_html'); + } +}); + +it('works when the type folder itself is a symlink to a shared folder', function () { + mkdir($this->tmp . '/shared/images/team', 0777, true); + mediaBrowserSymlink($this->tmp . '/shared/images', $this->tmp . '/images-shared'); + try { + $browser = mediaBrowserForTypeDir($this->tmp . '/images-shared'); + + expect(mediaBrowserIsInside($browser, $this->tmp . '/images-shared/team'))->toBeTrue() + ->and(mediaBrowserIsInside($browser, $this->tmp . '/images-shared/team/new.png'))->toBeTrue() + ->and(mediaBrowserIsInside($browser, $this->tmp . '/images/team'))->toBeFalse(); + } finally { + @unlink($this->tmp . '/images-shared') || @rmdir($this->tmp . '/images-shared'); + @rmdir($this->tmp . '/shared/images/team'); + @rmdir($this->tmp . '/shared/images'); + @rmdir($this->tmp . '/shared'); + } +}); + +it('reports an ordinary folder below the type folder as writable', function () { + require_once dirname(__DIR__, 4) . '/manager/media/browser/mcpuk/lib/helper_path.php'; + require_once dirname(__DIR__, 4) . '/manager/media/browser/mcpuk/lib/helper_dir.php'; + foreach (['config' => ['uploadDir' => $this->tmp], 'session' => ['dir' => 'images']] as $name => $value) { + $property = new ReflectionProperty(uploader::class, $name); + $property->setAccessible(true); + $property->setValue($this->browser, $value); + } + $method = new ReflectionMethod(browser::class, 'getDirInfo'); + $method->setAccessible(true); + $previousSession = $_SESSION ?? null; + // an administrator skips the file groups, leaving only the path checks + $_SESSION = ['mgrRole' => 1]; + try { + $team = $method->invoke($this->browser, $this->tmp . '/images/team'); + $root = $method->invoke($this->browser, $this->tmp . '/images'); + } finally { + $_SESSION = $previousSession; + } + + expect($team['writable'])->toBeTrue() + ->and($team['removable'])->toBeTrue() + ->and($root['removable'])->toBeFalse(); +}); + +it('routes every folder and entry check through the containment check', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/media/browser/mcpuk/core/browser.php'); + + foreach (['postDir', 'getDir', 'isPathAccessible', 'filterAccessiblePaths', 'isWriteAllowed', 'subtreeAccessFilter'] as $method) { + $start = strpos($source, 'protected function ' . $method . '('); + expect($start)->not->toBeFalse(); + expect(substr($source, $start, 700))->toContain('isInsideTypeDir'); + } +}); diff --git a/install/src/controllers/summary.php b/install/src/controllers/summary.php index 01ab146830..ca10d9be40 100644 --- a/install/src/controllers/summary.php +++ b/install/src/controllers/summary.php @@ -75,11 +75,11 @@ function f_owc($path, $data, $mode = null): bool 'zip' => true, // ZipArchive requirement. ext-zip is no longer bundled with PHP7.4+ so must be installed and enabled ]; -$loaded_extensions = get_loaded_extensions(); +$missing_extensions = missingInstallExtensions($required_extensions); foreach ($required_extensions as $ext_name => $is_mandatory) { echo '

' . str_replace('[+extensions+]', $ext_name, $_lang['checking_extensions']); - if (!in_array($ext_name, $loaded_extensions)) { + if (array_key_exists($ext_name, $missing_extensions)) { if ($is_mandatory) { echo '' . $_lang['failed'] . '

'; $errors++; diff --git a/install/src/functions.php b/install/src/functions.php index 83530a00c2..aff6d4df4c 100644 --- a/install/src/functions.php +++ b/install/src/functions.php @@ -367,6 +367,24 @@ function validateLangCode($langCode) return $langCode; } +/** + * Returns the extensions from the given list that are not loaded. + * + * get_loaded_extensions() reports names in PHP's own casing (Reflection, SimpleXML), + * so the names are checked with the case-insensitive extension_loaded() instead. + * + * @param array $extensions extension name => is mandatory + * @return array + */ +function missingInstallExtensions(array $extensions): array +{ + return array_filter( + $extensions, + static fn ($name) => !extension_loaded((string) $name), + ARRAY_FILTER_USE_KEY + ); +} + /** * @return array */ diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php index 33e3ab3426..801cce9706 100755 --- a/manager/actions/files.dynamic.php +++ b/manager/actions/files.dynamic.php @@ -23,35 +23,7 @@ $editablefiles = add_dot($editablefiles); $inlineviewablefiles = add_dot($inlineviewablefiles); $viewablefiles = add_dot($viewablefiles); -$protected_path = []; -/* jp only if($_SESSION['mgrRole']!=1) { */ -$protected_path[] = str_replace('\\', '/', EVO_MANAGER_PATH); -$protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'temp/backup'); -$protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/backup'); -if (!evo()->hasPermission('save_plugin')) { - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/plugins'); -} -if (!evo()->hasPermission('save_snippet')) { - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/snippets'); -} -if (!evo()->hasPermission('save_template')) { - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/templates'); -} -if (!evo()->hasPermission('save_module')) { - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/modules'); -} -if (!evo()->hasPermission('empty_cache')) { - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/cache'); -} -if (!evo()->hasPermission('import_static')) { - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'temp/import'); - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/import'); -} -if (!evo()->hasPermission('export_static')) { - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'temp/export'); - $protected_path[] = str_replace('\\', '/', EVO_BASE_PATH . 'assets/export'); -} -/* } */ +$protected_path = fileManagerProtectedPaths(); // Mod added by Raymond $enablefileunzip = true; $enablefiledownload = true; @@ -87,6 +59,10 @@ || !is_readable($startpath)) { evo()->webAlertAndQuit($_lang["files_access_denied"]); } +// before any action runs: every one of them works on $startpath or an entry in it +if (fileManagerPathIsProtected($startpath, $protected_path)) { + evo()->webAlertAndQuit($_lang["files.dynamic.php2"]); +} // Raymond: get web start path for showing pictures $relative_path = ltrim(substr($startpath, strlen($filemanager_path)), '/'); @@ -155,21 +131,6 @@ function fileManagerDirectoryZipExists(array $zipPaths): bool } } -if (!function_exists('fileManagerPathIsProtected')) { - function fileManagerPathIsProtected(string $path, array $protectedPaths): bool - { - $path = rtrim(str_replace('\\', '/', $path), '/'); - foreach ($protectedPaths as $protectedPath) { - $protectedPath = rtrim(str_replace('\\', '/', $protectedPath), '/'); - if ($path === $protectedPath || strpos($path, $protectedPath . '/') === 0) { - return true; - } - } - - return false; - } -} - if (!function_exists('fileManagerDeleteDirectoryZip')) { function fileManagerDeleteDirectoryZip(array $zipPaths): void { @@ -486,9 +447,6 @@ function renameFile(file) { } } echo $directoryZipMessage; - if (in_array($startpath, $protected_path)) { - evo()->webAlertAndQuit($_lang["files.dynamic.php2"]); - } $tpl = '[+subject+]'; $ph = []; $ph['style_path'] = $theme_image_path; @@ -530,41 +488,7 @@ function renameFile(file) { // check to see user isn't trying to move below the document_root // Existing check replaced with realpath check above - // Define safe unzip function - function safe_unzip($file, $path) { - $path = rtrim(str_replace('\\', '/', realpath($path)), '/\\'); - $zip = new ZipArchive(); - if ($zip->open($file) !== true) { - return false; - } - for ($i = 0; $i < $zip->numFiles; $i++) { - $stat = $zip->statIndex($i); - $filename = str_replace('\\', '/', $stat['name']); - if (substr($filename, 0, 1) == '/' || strpos($filename, '..') !== false || strpos($filename, ':') !== false) { - continue; // skip malicious paths - } - $target = $path . '/' . $filename; - $target_real = rtrim(str_replace('\\', '/', realpath(dirname($target)) ?: dirname($target)), '/\\'); - if (!\EvolutionCMS\Support\FileManagerAccess::isWithin($path, $target_real)) { - continue; - } - if (substr($filename, -1) == '/') { - if (!is_dir($target)) { - mkdir($target, 0777, true); - } - } else { - $dirname = dirname($target); - if (!is_dir($dirname)) { - mkdir($dirname, 0777, true); - } - file_put_contents($target, $zip->getFromIndex($i)); - } - } - $zip->close(); - return true; - } - - // Unzip .zip files - by Raymond, with safe_unzip + // Unzip .zip files - by Raymond if ($enablefileunzip && get_by_key($_REQUEST, 'mode') == 'unzip' && $currentPathWritable) { if ($token_check) { $zipTarget = fileManagerResolvePath($filemanager_path, $relative_path . '/' . ($_REQUEST['file'] ?? '')); @@ -574,7 +498,7 @@ function safe_unzip($file, $path) { // Unpacking a restricted archive into this folder would publish its contents here echo '' . $_lang['files_access_denied'] . '

'; } else { - $success = safe_unzip($zipTarget['path'], $startpath); + $success = fileManagerExtractZip($zipTarget['path'], $startpath, $protected_path); if (!$success) { echo '' . $_lang['file_unzip_fail'] . '

'; } else { @@ -619,6 +543,7 @@ function safe_unzip($file, $path) { if ($folderTarget === null || !is_dir($folder)) { echo 'Invalid path.

'; } elseif (!fileManagerCanModifyExistingPath($folderTarget['relative'], $userGroups) + || fileManagerPathTouchesProtected($folder, $protected_path) || fileManagerHasInaccessibleDescendants($folderTarget['relative']) || !is_writable($folder)) { echo '' . $_lang['files_access_denied'] . '

'; @@ -715,12 +640,16 @@ function safe_unzip($file, $path) { $dirname = $dirTarget['path'] ?? ''; if ($dirTarget === null || !is_dir($dirname)) { echo 'Invalid path.

'; - } elseif (!fileManagerCanModifyExistingPath($dirTarget['relative'], $userGroups) || !is_writable($dirname)) { + } elseif (!fileManagerCanModifyExistingPath($dirTarget['relative'], $userGroups) + || fileManagerPathTouchesProtected($dirname, $protected_path) + || !is_writable($dirname)) { echo '' . $_lang['files_access_denied'] . '

'; } else { $newDirname = str_replace([ '..\\', '../', '\\', '/' ], '', $_REQUEST['newDirname']); if (preg_match('@(\\\\|\/|\:|\;|\,|\*|\?|\"|\<|\>|\||\?)@', $newDirname) !== 0) { echo $_lang['files.dynamic.php3']; + } elseif (fileManagerPathIsProtected(dirname($dirname) . '/' . $newDirname, $protected_path)) { + echo '' . $_lang['files_access_denied'] . '

'; } else if (!rename($dirname, dirname($dirname) . '/' . $newDirname)) { echo '', $_lang['file_folder_not_created'], '

'; } else { diff --git a/manager/media/browser/mcpuk/core/browser.php b/manager/media/browser/mcpuk/core/browser.php index aa61c90892..cb28134d6b 100755 --- a/manager/media/browser/mcpuk/core/browser.php +++ b/manager/media/browser/mcpuk/core/browser.php @@ -115,7 +115,7 @@ public function action() } else { $type = $this->getTypeFromPath($this->session['dir']); $dir = $this->config['uploadDir'] . "/" . $this->session['dir']; - if (($type != $this->type) || !is_dir($dir) || !is_readable($dir)) { + if (($type != $this->type) || !is_dir($dir) || !is_readable($dir) || !$this->isInsideTypeDir($dir)) { $this->session['dir'] = $this->type; } } @@ -144,7 +144,8 @@ protected function act_browser() { if (isset($this->get['dir']) && is_dir("{$this->typeDir}/{$this->get['dir']}") && - is_readable("{$this->typeDir}/{$this->get['dir']}") + is_readable("{$this->typeDir}/{$this->get['dir']}") && + $this->isInsideTypeDir("{$this->typeDir}/{$this->get['dir']}") ) { $this->session['dir'] = path::normalize("{$this->type}/{$this->get['dir']}"); } @@ -1168,7 +1169,7 @@ protected function postDir($existent = true) if (isset($this->post['dir'])) { $dir .= "/" . $this->post['dir']; } - if ($existent && (!is_dir($dir) || !is_readable($dir))) { + if (($existent && (!is_dir($dir) || !is_readable($dir))) || !$this->isInsideTypeDir($dir)) { $this->errorMsg("Inexistant or inaccessible folder."); } @@ -1185,13 +1186,47 @@ protected function getDir($existent = true) if (isset($this->get['dir'])) { $dir .= "/" . $this->get['dir']; } - if ($existent && (!is_dir($dir) || !is_readable($dir))) { + if (($existent && (!is_dir($dir) || !is_readable($dir))) || !$this->isInsideTypeDir($dir)) { $this->errorMsg("Inexistant or inaccessible folder."); } return $dir; } + /** + * 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. + * + * @param string $absPath + * @return bool + */ + protected function isInsideTypeDir($absPath) + { + $root = realpath($this->typeDir); + if ($root === false) { + return false; + } + $path = realpath($absPath); + if ($path === false) { + // nothing there yet is fine, a dangling link is not: writing to it creates its target + if (is_link($absPath)) { + return false; + } + $parent = realpath(dirname($absPath)); + + return $parent !== false && \EvolutionCMS\Support\FileManagerAccess::isWithin( + str_replace('\\', '/', $root), + str_replace('\\', '/', $parent) + ); + } + + return \EvolutionCMS\Support\FileManagerAccess::isWithin( + str_replace('\\', '/', $root), + str_replace('\\', '/', $path) + ); + } + /** * @param $dir * @return array @@ -1228,15 +1263,16 @@ protected function getDirInfo($dir, $removable = false) $dirs = glob($dir.'/*',GLOB_ONLYDIR); $hasDirs = !empty($dirs); - $relativePath = $this->getFileGroupsRelPath($dir); - $writable = dir::isWritable($dir) && $this->isWriteAllowed($this->removeTypeFromPath($relativePath)); + // isWriteAllowed() takes a path below the type folder, not below the site root + $typeRelativePath = \EvolutionCMS\Support\FileManagerAccess::getRelativePath($this->typeDir, $dir); + $writable = dir::isWritable($dir) && $this->isWriteAllowed($typeRelativePath); $info = [ 'name' => stripslashes(basename($dir)), 'readable' => is_readable($dir), 'writable' => $writable, 'removable' => $writable && dir::isWritable(dirname($dir)) - && $this->isStrictWriteAllowed($this->removeTypeFromPath($relativePath)), + && $this->isStrictWriteAllowed($typeRelativePath), 'hasDirs' => $hasDirs ]; @@ -1301,6 +1337,8 @@ protected function getFileGroupsRelPath(string $absPath): string */ protected function filterAccessiblePaths(array $absPaths): array { + // a symlink out of the upload folder is neither listed nor followed + $absPaths = array_values(array_filter($absPaths, [$this, 'isInsideTypeDir'])); if (!$this->modx->getConfig('use_udperms')) { return $absPaths; } @@ -1341,6 +1379,11 @@ protected function filterAccessiblePaths(array $absPaths): array */ protected function isWriteAllowed($relDir) { + $relDir = trim($relDir, '/'); + $absPath = $this->typeDir . ($relDir !== '' ? '/' . $relDir : ''); + if (!$this->isInsideTypeDir($absPath)) { + return false; + } if (isset($_SESSION['mgrRole']) && (int)$_SESSION['mgrRole'] === 1) { return true; } @@ -1348,8 +1391,6 @@ protected function isWriteAllowed($relDir) return true; } - $relDir = trim($relDir, '/'); - $absPath = $this->typeDir . ($relDir !== '' ? '/' . $relDir : ''); $relPath = $this->getFileGroupsRelPath($absPath); if ($relPath === '') { return true; @@ -1370,6 +1411,9 @@ protected function isWriteAllowed($relDir) */ protected function isPathAccessible($absPath) { + if (!$this->isInsideTypeDir($absPath)) { + return false; + } if (isset($_SESSION['mgrRole']) && (int)$_SESSION['mgrRole'] === 1) { return true; } @@ -1400,13 +1444,16 @@ protected function isPathAccessible($absPath) protected function subtreeAccessFilter($absDir) { if ((isset($_SESSION['mgrRole']) && (int)$_SESSION['mgrRole'] === 1) || !$this->modx->getConfig('use_udperms')) { - return static fn ($path) => true; + return fn ($path) => $this->isInsideTypeDir($path); } $restrictions = \EvolutionCMS\Support\FileManagerAccess::loadSubtreeRestrictions($this->getFileGroupsRelPath($absDir)); $userGroups = array_map('intval', (array)($_SESSION['mgrDocgroups'] ?? [])); return function ($path) use ($restrictions, $userGroups) { + if (!$this->isInsideTypeDir($path)) { + return false; + } $relPath = $this->getFileGroupsRelPath($path); return $relPath === '' From 6dee3962b8c0e9c5258c7a11c5a401aeba6a6c3a Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 28 Sep 2026 16:44:56 +0200 Subject: [PATCH 2/2] fix(core): symlink access check in file browser and file manager --- core/functions/actions/files.php | 172 ++++++++- core/lang/bg/global.php | 2 + core/lang/cs/global.php | 2 + core/lang/da/global.php | 2 + core/lang/fa/global.php | 2 + core/lang/fi/global.php | 2 + core/lang/fr/global.php | 2 + core/lang/he/global.php | 2 + core/lang/nn/global.php | 2 + core/lang/pt/global.php | 2 + core/lang/sv/global.php | 2 + core/lang/zh/global.php | 2 + core/tests/Pest.php | 30 ++ core/tests/Unit/CommittedClassmapTest.php | 4 +- .../Unit/DefineConstantsCompatibilityTest.php | 9 +- .../Install/CliInstallExecAutoloadTest.php | 4 +- .../InstallerLanguageSelectionTest.php | 2 +- .../Unit/Manager/ChunkFileStoreGuardsTest.php | 2 +- core/tests/Unit/NoSessionModeTest.php | 4 +- .../Security/ApacheConfigHardeningTest.php | 96 ++++- .../Security/FileManagerSymlinkWritesTest.php | 337 ++++++++++++++++++ .../FileManagerWritePathChecksTest.php | 4 +- core/tests/Unit/SiteUrlFromHostHeaderTest.php | 8 +- core/tests/Unit/TestProcessHelpersTest.php | 125 +++++++ manager/actions/files.dynamic.php | 14 +- manager/media/browser/mcpuk/core/browser.php | 22 +- .../media/browser/mcpuk/lib/helper_dir.php | 46 ++- .../media/browser/mcpuk/lib/helper_file.php | 3 +- 28 files changed, 863 insertions(+), 41 deletions(-) create mode 100644 core/tests/Unit/Security/FileManagerSymlinkWritesTest.php create mode 100644 core/tests/Unit/TestProcessHelpersTest.php diff --git a/core/functions/actions/files.php b/core/functions/actions/files.php index f66505d1d0..789590611b 100644 --- a/core/functions/actions/files.php +++ b/core/functions/actions/files.php @@ -238,6 +238,38 @@ function fileManagerPathTouchesProtected($path, array $protectedPaths) } } +if(!function_exists('fileManagerIsLink')) { + /** + * Whether $path is a link of any kind: a symlink, or on Windows a junction or other + * reparse point, which is_link() does not report (PHP sees a junction as a plain folder). + * So an existing entry counts as a link when it does not resolve to itself. readlink() + * cannot decide it: on Windows it returns a path for ordinary files and folders too. + * + * @param string $path + */ + function fileManagerIsLink($path) + { + clearstatcache(true, $path); + if (is_link($path)) { + return true; + } + if (!file_exists($path)) { + // something is there that leads nowhere: a dangling junction, which only lstat() + // sees (is_link() above already caught a dangling symlink) + return PHP_OS_FAMILY === 'Windows' && @lstat($path) !== false; + } + $real = realpath($path); + $parent = realpath(dirname($path)); + if ($real === false || $parent === false) { + return true; + } + $expected = rtrim(str_replace('\\', '/', $parent), '/') . '/' . basename($path); + $real = str_replace('\\', '/', $real); + + return PHP_OS_FAMILY === 'Windows' ? strcasecmp($real, $expected) !== 0 : $real !== $expected; + } +} + if(!function_exists('fileManagerIsSafeWriteTarget')) { /** * Whether writing to $target, which need not exist yet, stays under $root. Every part of @@ -261,7 +293,7 @@ function fileManagerIsSafeWriteTarget($root, $target) return false; } $current .= '/' . $segment; - if (is_link($current)) { + if (fileManagerIsLink($current)) { return false; } if (!file_exists($current)) { @@ -274,6 +306,118 @@ function fileManagerIsSafeWriteTarget($root, $target) } } +if(!function_exists('fileManagerIsNewWriteTarget')) { + /** + * Whether $target is a safe place for a new file or folder: it stays under $root and + * nothing is there yet, not even a dangling symlink that a write would follow. + * + * @param string $root canonical absolute path + * @param string $target absolute path below $root + */ + function fileManagerIsNewWriteTarget($root, $target) + { + return fileManagerIsSafeWriteTarget($root, $target) && !file_exists($target) && !fileManagerIsLink($target); + } +} + +if(!function_exists('fileManagerCreateFile')) { + /** + * Creates $target with $content, but only as a new file: an existing file or symlink of + * that name is left alone, so the write can never truncate or follow it. + * + * @param string $root canonical absolute path + * @param string $target absolute path below $root + * @param string $content + */ + function fileManagerCreateFile($root, $target, $content = '') + { + if (!fileManagerIsNewWriteTarget($root, $target)) { + return false; + } + // "x" fails when the name appeared in the meantime, including as a symlink + $handle = @fopen($target, 'xb'); + if ($handle === false) { + return false; + } + $written = fwrite($handle, $content) === strlen($content); + fclose($handle); + + return $written; + } +} + +if(!function_exists('fileManagerCopyToNewFile')) { + /** + * Copies $source to $target, which must not exist yet (see fileManagerCreateFile()). + * + * @param string $root canonical absolute path + * @param string $source existing file + * @param string $target absolute path below $root + */ + function fileManagerCopyToNewFile($root, $source, $target) + { + if (!is_file($source) || !fileManagerIsNewWriteTarget($root, $target)) { + return false; + } + $in = @fopen($source, 'rb'); + if ($in === false) { + return false; + } + $out = @fopen($target, 'xb'); + if ($out === false) { + fclose($in); + + return false; + } + $copied = stream_copy_to_stream($in, $out) !== false; + fclose($in); + fclose($out); + if (!$copied) { + @unlink($target); + } + + return $copied; + } +} + +if(!function_exists('fileManagerCanRenameTo')) { + /** + * Whether $source may be renamed to $target: nothing is at the new name yet, so a rename + * cannot replace another file (or one the user may not modify). A case-only rename is + * allowed too, as on Windows the new name finds the file itself. + * + * @param string $root canonical absolute path + * @param string $source existing entry + * @param string $target absolute path below $root + */ + function fileManagerCanRenameTo($root, $source, $target) + { + if (fileManagerIsNewWriteTarget($root, $target)) { + return true; + } + if (!fileManagerIsSafeWriteTarget($root, $target) + || strcasecmp(str_replace('\\', '/', $source), str_replace('\\', '/', $target)) !== 0) { + return false; + } + $sourceReal = realpath($source); + + return $sourceReal !== false && $sourceReal === realpath($target); + } +} + +if(!function_exists('fileManagerRemoveLink')) { + /** + * Removes the symlink $path itself, never what it points to. A directory symlink or + * junction on Windows is removed with rmdir. + * + * @param string $path + */ + function fileManagerRemoveLink($path) + { + return @unlink($path) || @rmdir($path); + } +} + if(!function_exists('fileManagerExtractZip')) { /** * Extracts $file into $path, skipping any entry that would land outside it, go through a @@ -803,21 +947,37 @@ function unzip($file, $path) if(!function_exists('rrmdir')) { /** + * Deletes $dir and everything in it. Symlinks are removed as links: the walk never enters + * them, so a link to a folder elsewhere cannot take that folder's content with it. + * * @param string $dir * @return bool */ function rrmdir($dir) { - $dir = str_replace('\\', '/', realpath($dir)); // Canonicalize path - foreach (glob($dir . '/*') as $file) { - if (is_dir($file)) { + $dir = rtrim(str_replace('\\', '/', $dir), '/'); + if (fileManagerIsLink($dir)) { + return fileManagerRemoveLink($dir); + } + $items = @scandir($dir); + if ($items === false) { + return false; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $file = $dir . '/' . $item; + if (fileManagerIsLink($file)) { + fileManagerRemoveLink($file); + } elseif (is_dir($file)) { rrmdir($file); } else { - unlink($file); + @unlink($file); } } - return rmdir($dir); + return @rmdir($dir); } } diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 6684bf6838..c4cba62e73 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -1165,6 +1165,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'Файлът не може да бъде дублиран.'; +$_lang["files.dynamic.php6"] = 'Файлът или папката не могат да бъдат преименувани.'; $_lang["files_dynamic_new_folder_name"] = 'Enter new directory name:'; $_lang["files_dynamic_new_file_name"] = 'Enter new file name:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 3301b9f2df..4662adf90a 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -1169,6 +1169,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'Soubor nelze duplikovat.'; +$_lang["files.dynamic.php6"] = 'Soubor nebo složku nelze přejmenovat.'; $_lang["files_dynamic_new_folder_name"] = 'Enter new directory name:'; $_lang["files_dynamic_new_file_name"] = 'Enter new file name:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index 2fcb8e7f28..a6cb73b851 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -1168,6 +1168,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'Filen kunne ikke duplikeres.'; +$_lang["files.dynamic.php6"] = 'Filen eller mappen kunne ikke omdøbes.'; $_lang["files_dynamic_new_folder_name"] = 'Enter new directory name:'; $_lang["files_dynamic_new_file_name"] = 'Enter new file name:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 6c7669d472..7c5fce516b 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -1166,6 +1166,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'امکان تکثیر فایل وجود ندارد.'; +$_lang["files.dynamic.php6"] = 'امکان تغییر نام فایل یا پوشه وجود ندارد.'; $_lang["files_dynamic_new_folder_name"] = 'Enter new directory name:'; $_lang["files_dynamic_new_file_name"] = 'Enter new file name:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index e906a98307..8aca02f97d 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -1164,6 +1164,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'Tiedostoa ei voitu monistaa.'; +$_lang["files.dynamic.php6"] = 'Tiedostoa tai kansiota ei voitu nimetä uudelleen.'; $_lang["files_dynamic_new_folder_name"] = 'Syötä uuden hakemiston nimi:'; $_lang["files_dynamic_new_file_name"] = 'Syötä uusi tiedostonimi:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index 807f1da0e2..f06166d1bb 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -1135,6 +1135,8 @@ $_lang["files.dynamic.php2"] = 'Ce dossier ne peut être affiché.'; $_lang["files.dynamic.php3"] = 'Il y a un problème dans le nom du fichier.'; $_lang["files.dynamic.php4"] = 'Le fichier texte a été crée.'; +$_lang["files.dynamic.php5"] = 'Le fichier n\'a pas pu être dupliqué.'; +$_lang["files.dynamic.php6"] = 'Le fichier ou le dossier n\'a pas pu être renommé.'; $_lang["files_dynamic_new_folder_name"] = 'Saisissez un nouveau nom de dossier :'; $_lang["files_dynamic_new_file_name"] = 'Saisissez un nouveau nom de fichier :'; $_lang["not_readable_dir"] = 'Dossier illisible.'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index 2006bb504f..39e687df60 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -1166,6 +1166,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'לא ניתן לשכפל את הקובץ.'; +$_lang["files.dynamic.php6"] = 'לא ניתן לשנות את שם הקובץ או התיקייה.'; $_lang["files_dynamic_new_folder_name"] = 'Enter new directory name:'; $_lang["files_dynamic_new_file_name"] = 'Enter new file name:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 0a737347f2..5e2df67395 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -1132,6 +1132,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'Fila kunne ikkje dupliserast.'; +$_lang["files.dynamic.php6"] = 'Fila eller mappa kunne ikkje få nytt namn.'; $_lang["files_dynamic_new_folder_name"] = 'Enter new directory name:'; $_lang["files_dynamic_new_file_name"] = 'Enter new file name:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index 9530d7f931..eae0f57611 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -1165,6 +1165,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'Não foi possível duplicar o arquivo.'; +$_lang["files.dynamic.php6"] = 'Não foi possível renomear o arquivo ou a pasta.'; $_lang["files_dynamic_new_folder_name"] = 'Digite o novo nome do diretório:'; $_lang["files_dynamic_new_file_name"] = 'Digite o novo nome do arquivo:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index ee9766dda3..06ba01d394 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -1165,6 +1165,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = 'Filen kunde inte dupliceras.'; +$_lang["files.dynamic.php6"] = 'Filen eller mappen kunde inte byta namn.'; $_lang["files_dynamic_new_folder_name"] = 'Ange nytt katalognamn:'; $_lang["files_dynamic_new_file_name"] = 'Ange nytt filnamn:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index a30d0d7ab8..1c56307875 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -1166,6 +1166,8 @@ $_lang["files.dynamic.php2"] = 'This directory cannot be displayed.'; $_lang["files.dynamic.php3"] = 'There is a problem in a file name.'; $_lang["files.dynamic.php4"] = 'The text file was created.'; +$_lang["files.dynamic.php5"] = '无法复制文件。'; +$_lang["files.dynamic.php6"] = '无法重命名文件或目录。'; $_lang["files_dynamic_new_folder_name"] = 'Enter new directory name:'; $_lang["files_dynamic_new_file_name"] = 'Enter new file name:'; $_lang["not_readable_dir"] = 'Can not read this directory.'; diff --git a/core/tests/Pest.php b/core/tests/Pest.php index b239048cc7..ad0f5b4d36 100644 --- a/core/tests/Pest.php +++ b/core/tests/Pest.php @@ -43,3 +43,33 @@ function something() { // .. } + +/** + * Runs $command and returns its output, lines joined with "\n"; $exitCode receives its exit + * code. ExecWithFallback tries exec(), passthru(), proc_open() and popen() in turn, so a test + * still runs where some of them are disabled; it throws when none is available. + */ +function evoRunCommand(string $command, ?int &$exitCode = null): string +{ + $output = []; + \ExecWithFallback\ExecWithFallback::exec($command, $output, $exitCode); + + return implode("\n", $output); +} + +/** + * Runs the PHP script $script in a fresh PHP process, for what must not leak between tests + * (constants, globals, loaded classes). stderr joins the output unless $captureStderr is false. + * + * @param string[] $args passed to the script, each shell-escaped (on Windows escapeshellarg() + * turns " % ! into spaces) + */ +function evoRunPhp(string $script, array $args = [], ?int &$exitCode = null, bool $captureStderr = true): string +{ + $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script); + foreach ($args as $arg) { + $command .= ' ' . escapeshellarg((string) $arg); + } + + return evoRunCommand($command . ($captureStderr ? ' 2>&1' : ''), $exitCode); +} diff --git a/core/tests/Unit/CommittedClassmapTest.php b/core/tests/Unit/CommittedClassmapTest.php index 0d2e5cc6b7..c8a29dbde9 100644 --- a/core/tests/Unit/CommittedClassmapTest.php +++ b/core/tests/Unit/CommittedClassmapTest.php @@ -35,8 +35,8 @@ function srcClassNames(): array } test('every class under core/src is in the committed classmap', function (string $map) { - $committed = shell_exec('git -C ' . escapeshellarg(dirname(__DIR__, 2)) . ' show HEAD:core/vendor/composer/' . $map . ' 2>&1'); - if (!is_string($committed) || !str_contains($committed, 'EvolutionCMS\\\\')) { + $committed = evoRunCommand('git -C ' . escapeshellarg(dirname(__DIR__, 2)) . ' show HEAD:core/vendor/composer/' . $map . ' 2>&1', $exitCode); + if ($exitCode !== 0 || !str_contains($committed, 'EvolutionCMS\\\\')) { $this->markTestSkipped('no git checkout to read the committed classmap from'); } diff --git a/core/tests/Unit/DefineConstantsCompatibilityTest.php b/core/tests/Unit/DefineConstantsCompatibilityTest.php index 8fb9cc50f4..d67cc1731b 100644 --- a/core/tests/Unit/DefineConstantsCompatibilityTest.php +++ b/core/tests/Unit/DefineConstantsCompatibilityTest.php @@ -75,14 +75,13 @@ function loadBootConstantsInFreshProcess(array $env): array $scriptPath = tempnam(sys_get_temp_dir(), 'evo-constants-'); file_put_contents($scriptPath, sprintf($script, var_export($rootDir, true), var_export($env, true))); - $output = []; - $status = 0; - exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($scriptPath), $output, $status); + // stdout only: the JSON must not pick up anything the script reports on stderr + $output = evoRunPhp($scriptPath, [], $status, false); @unlink($scriptPath); - expect($status)->toBe(0, implode("\n", $output)); + expect($status)->toBe(0, $output); - return json_decode(implode("\n", $output), true, 512, JSON_THROW_ON_ERROR); + return json_decode($output, true, 512, JSON_THROW_ON_ERROR); } test('evo bootstrap constants publish modx aliases', function () { diff --git a/core/tests/Unit/Install/CliInstallExecAutoloadTest.php b/core/tests/Unit/Install/CliInstallExecAutoloadTest.php index c9e6371e81..4345e860f6 100644 --- a/core/tests/Unit/Install/CliInstallExecAutoloadTest.php +++ b/core/tests/Unit/Install/CliInstallExecAutoloadTest.php @@ -20,7 +20,7 @@ . ' define("EVO_CORE_PATH", ' . var_export($root . '/core/', true) . ');' . ' require ' . var_export($root . '/install/src/functions.php', true) . ';' . ' echo json_encode([loadExecWithFallback(), class_exists("ExecWithFallback\\ExecWithFallback"), class_exists("ExecWithFallback\\Availability")]);'); - $out = trim((string) shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($probe) . ' 2>&1')); + $out = trim(evoRunPhp($probe)); unlink($probe); expect($out)->toBe('[true,true,true]'); @@ -33,7 +33,7 @@ . ' define("EVO_CORE_PATH", ' . var_export($core, true) . ');' . ' require ' . var_export(dirname(__DIR__, 4) . '/install/src/functions.php', true) . ';' . ' var_export(loadExecWithFallback());'); - $out = trim((string) shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($probe) . ' 2>&1')); + $out = trim(evoRunPhp($probe)); unlink($probe); expect($out)->toBe('false'); diff --git a/core/tests/Unit/Install/InstallerLanguageSelectionTest.php b/core/tests/Unit/Install/InstallerLanguageSelectionTest.php index bc05834821..078778c742 100644 --- a/core/tests/Unit/Install/InstallerLanguageSelectionTest.php +++ b/core/tests/Unit/Install/InstallerLanguageSelectionTest.php @@ -25,7 +25,7 @@ private function resolve(array $get = [], array $post = [], string $acceptLangua $tmp = tempnam(sys_get_temp_dir(), 'evo-lang-'); file_put_contents($tmp, $script); try { - $output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($tmp) . ' 2>&1'); + $output = evoRunPhp($tmp); } finally { unlink($tmp); } diff --git a/core/tests/Unit/Manager/ChunkFileStoreGuardsTest.php b/core/tests/Unit/Manager/ChunkFileStoreGuardsTest.php index 2377aa0888..7be7002dc6 100644 --- a/core/tests/Unit/Manager/ChunkFileStoreGuardsTest.php +++ b/core/tests/Unit/Manager/ChunkFileStoreGuardsTest.php @@ -33,7 +33,7 @@ $probe = tempnam(sys_get_temp_dir(), 'evo_chunk_probe_'); file_put_contents($probe, 'displayDirectory();'); - $out = trim((string) shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($probe) . ' 2>&1')); + $out = trim(evoRunPhp($probe)); unlink($probe); expect($out)->toBe('views/chunks/'); diff --git a/core/tests/Unit/NoSessionModeTest.php b/core/tests/Unit/NoSessionModeTest.php index fff1b65508..78ee9ef6d9 100644 --- a/core/tests/Unit/NoSessionModeTest.php +++ b/core/tests/Unit/NoSessionModeTest.php @@ -9,8 +9,8 @@ function noSessionRun(string $noSession, string $manager): array { $worker = dirname(__DIR__) . '/Mocks/no_session_worker.php'; - $out = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($worker) . " $noSession $manager 2>&1"); - $result = json_decode((string) $out, true); + $out = evoRunPhp($worker, [$noSession, $manager]); + $result = json_decode($out, true); expect($result)->toBeArray("worker output: $out"); return $result; diff --git a/core/tests/Unit/Security/ApacheConfigHardeningTest.php b/core/tests/Unit/Security/ApacheConfigHardeningTest.php index 030b098ef4..34b4d1a246 100644 --- a/core/tests/Unit/Security/ApacheConfigHardeningTest.php +++ b/core/tests/Unit/Security/ApacheConfigHardeningTest.php @@ -50,16 +50,106 @@ function evoFile(string $relative): string // a bare 2.2 directive outside its guard is a 500 on a 2.4 server without mod_access_compat expect(preg_match('/^\s*(Order|Deny from|Allow from)\b/mi', preg_replace('/.*?<\/IfModule>/s', '', $source))) ->toBe(0); -})->with(function () { - $root = dirname(__DIR__, 4); +})->with(fn () => evoShippedHtaccessFiles(dirname(__DIR__, 4))); + +/** + * The .htaccess files the project ships, relative to $root: git's list when this is a checkout, + * otherwise (a release archive, a copy, a container where git refuses the directory) the ones + * on disk, minus folders that hold dependencies, runtime data or scratch work. + * + * @return string[] + */ +function evoShippedHtaccessFiles(string $root): array +{ + return evoTrackedHtaccessFiles($root) ?: evoScannedHtaccessFiles($root); +} + +/** + * @return string[] empty when $root is no git checkout or git is unusable here + */ +function evoTrackedHtaccessFiles(string $root): array +{ + $null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; + try { + $tracked = evoRunCommand('git -C ' . escapeshellarg($root) . ' ls-files 2>' . $null, $code); + } catch (\Exception $e) { + return []; // no way to run a process here + } + if ($code !== 0) { + return []; // no checkout, no git, or git refuses the directory (dubious ownership) + } $out = []; - foreach (explode("\n", trim((string)shell_exec('git -C ' . escapeshellarg($root) . ' ls-files'))) as $path) { + foreach (explode("\n", $tracked) as $path) { if (preg_match('~(^|/)\.htaccess$~', $path)) { $out[] = $path; } } + sort($out); return $out; +} + +/** + * @return string[] + */ +function evoScannedHtaccessFiles(string $root): array +{ + $root = rtrim(str_replace('\\', '/', $root), '/'); + // skipped wherever they are / only at these paths from the root + $skipNames = ['.git', 'node_modules', 'vendor']; + $skipPaths = ['tmp', 'core/storage']; + $relative = static fn (SplFileInfo $item) => ltrim(substr(str_replace('\\', '/', $item->getPathname()), strlen($root)), '/'); + + $iterator = new RecursiveIteratorIterator(new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + static function (SplFileInfo $item) use ($relative, $skipNames, $skipPaths) { + if ($item->isDir() && !$item->isLink()) { + return !in_array($item->getFilename(), $skipNames, true) && !in_array($relative($item), $skipPaths, true); + } + + return $item->getFilename() === '.htaccess'; + } + )); + $out = []; + foreach ($iterator as $item) { + $out[] = $relative($item); + } + sort($out); + + return $out; +} + +it('finds the shipped .htaccess files without git as well', function () { + $root = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-htaccess-' . bin2hex(random_bytes(6)); + $dirs = ['core', 'core/vendor/pkg', 'core/storage/cache', 'node_modules/x', 'tmp/evo/core', 'assets/cache', 'assets/tmp']; + foreach ($dirs as $dir) { + mkdir($root . '/' . $dir, 0777, true); + file_put_contents($root . '/' . $dir . '/.htaccess', 'Require all denied'); + } + file_put_contents($root . '/core/htaccess.txt', 'not one'); + try { + expect(evoScannedHtaccessFiles($root))->toBe(['assets/cache/.htaccess', 'assets/tmp/.htaccess', 'core/.htaccess']); + } finally { + @unlink($root . '/core/htaccess.txt'); + foreach ($dirs as $dir) { + @unlink($root . '/' . $dir . '/.htaccess'); + } + foreach (['core/vendor/pkg', 'core/vendor', 'core/storage/cache', 'core/storage', 'core', 'node_modules/x', 'node_modules', + 'tmp/evo/core', 'tmp/evo', 'tmp', 'assets/cache', 'assets/tmp', 'assets', ''] as $dir) { + @rmdir($root . '/' . $dir); + } + } +}); + +it('finds the same .htaccess files through git and on disk in this checkout', function () { + $root = dirname(__DIR__, 4); + $tracked = evoTrackedHtaccessFiles($root); + if ($tracked === []) { + test()->markTestSkipped('no usable git checkout here'); + } + + // git's list is the reference; the scan may add untracked files, but must not miss any + expect(array_values(array_diff($tracked, evoScannedHtaccessFiles($root))))->toBe([]); }); it('does not ship an installer .htaccess that strips the CSP the installer sets', function () { diff --git a/core/tests/Unit/Security/FileManagerSymlinkWritesTest.php b/core/tests/Unit/Security/FileManagerSymlinkWritesTest.php new file mode 100644 index 0000000000..bf9ae52cfb --- /dev/null +++ b/core/tests/Unit/Security/FileManagerSymlinkWritesTest.php @@ -0,0 +1,337 @@ +markTestSkipped('symlinks are not available here'); + } +} + +// a directory junction: Windows only, and unlike a symlink it needs no special privilege +function fmLinksJunction(string $target, string $link): void +{ + if (PHP_OS_FAMILY !== 'Windows') { + test()->markTestSkipped('junctions exist on Windows only'); + } + try { + $output = evoRunCommand('mklink /J ' . escapeshellarg(str_replace('/', '\\', $link)) . ' ' + . escapeshellarg(str_replace('/', '\\', $target)) . ' 2>&1', $code); + } catch (\Exception $e) { + test()->markTestSkipped('no way to run mklink here'); + } + if ($code !== 0) { + test()->markTestSkipped('junctions are not available here: ' . $output); + } +} + +function fmLinksBrowser(string $typeDir): browser +{ + $browser = (new ReflectionClass(browser::class))->newInstanceWithoutConstructor(); + $property = new ReflectionProperty(uploader::class, 'typeDir'); + $property->setAccessible(true); + $property->setValue($browser, $typeDir); + + return $browser; +} + +beforeEach(function () { + $tmp = sys_get_temp_dir() . '/evo-fm-links-' . bin2hex(random_bytes(6)); + mkdir($tmp . '/root/doomed/inner', 0777, true); + mkdir($tmp . '/outside', 0777, true); + $this->tmp = str_replace('\\', '/', realpath($tmp)); + $this->root = $this->tmp . '/root'; + file_put_contents($this->tmp . '/outside/keep.txt', 'outside'); + file_put_contents($this->root . '/doomed/inner/file.txt', 'inside'); + file_put_contents($this->root . '/doomed/.hidden', 'inside'); +}); + +afterEach(function () { + fmLinksRemove($this->tmp); +}); + +it('deletes a folder tree without following a nested folder symlink', function () { + fmLinksSymlink($this->tmp . '/outside', $this->root . '/doomed/inner/link'); + + expect(rrmdir($this->root . '/doomed'))->toBeTrue() + ->and(file_exists($this->root . '/doomed'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('deletes a nested file symlink without touching its target', function () { + fmLinksSymlink($this->tmp . '/outside/keep.txt', $this->root . '/doomed/keep.txt'); + + expect(rrmdir($this->root . '/doomed'))->toBeTrue() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('removes only the link when the folder to delete is itself a symlink', function () { + fmLinksSymlink($this->tmp . '/outside', $this->root . '/linked'); + + expect(rrmdir($this->root . '/linked'))->toBeTrue() + ->and(is_link($this->root . '/linked'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('creates a new empty file', function () { + expect(fileManagerCreateFile($this->root, $this->root . '/new.txt'))->toBeTrue() + ->and(file_get_contents($this->root . '/new.txt'))->toBe(''); +}); + +it('refuses to create a file over an existing one', function () { + expect(fileManagerCreateFile($this->root, $this->root . '/doomed/inner/file.txt'))->toBeFalse() + ->and(file_get_contents($this->root . '/doomed/inner/file.txt'))->toBe('inside'); +}); + +it('refuses to create a file through a symlink, dangling or not', function () { + fmLinksSymlink($this->tmp . '/outside/keep.txt', $this->root . '/keep.txt'); + fmLinksSymlink($this->tmp . '/outside/shell.php', $this->root . '/shell.php'); + + expect(fileManagerCreateFile($this->root, $this->root . '/keep.txt'))->toBeFalse() + ->and(fileManagerCreateFile($this->root, $this->root . '/shell.php', 'toBeFalse() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside') + ->and(file_exists($this->tmp . '/outside/shell.php'))->toBeFalse(); +}); + +it('refuses to create a file outside the root', function () { + expect(fileManagerCreateFile($this->root, $this->tmp . '/outside/new.txt'))->toBeFalse() + ->and(file_exists($this->tmp . '/outside/new.txt'))->toBeFalse(); +}); + +it('duplicates a file under a new name', function () { + $source = $this->root . '/doomed/inner/file.txt'; + + expect(fileManagerCopyToNewFile($this->root, $source, $this->root . '/doomed/inner/copy.txt'))->toBeTrue() + ->and(file_get_contents($this->root . '/doomed/inner/copy.txt'))->toBe('inside'); +}); + +it('refuses to duplicate over an existing file or through a symlink', function () { + $source = $this->root . '/doomed/inner/file.txt'; + file_put_contents($this->root . '/existing.txt', 'existing'); + fmLinksSymlink($this->tmp . '/outside/keep.txt', $this->root . '/keep.txt'); + fmLinksSymlink($this->tmp . '/outside/shell.php', $this->root . '/shell.php'); + + expect(fileManagerCopyToNewFile($this->root, $source, $this->root . '/existing.txt'))->toBeFalse() + ->and(fileManagerCopyToNewFile($this->root, $source, $this->root . '/keep.txt'))->toBeFalse() + ->and(fileManagerCopyToNewFile($this->root, $source, $this->root . '/shell.php'))->toBeFalse() + ->and(file_get_contents($this->root . '/existing.txt'))->toBe('existing') + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside') + ->and(file_exists($this->tmp . '/outside/shell.php'))->toBeFalse(); +}); + +it('treats an existing name or a symlinked folder name as no place for a new folder', function () { + fmLinksSymlink($this->tmp . '/outside', $this->root . '/linked'); + + expect(fileManagerIsNewWriteTarget($this->root, $this->root . '/fresh'))->toBeTrue() + ->and(fileManagerIsNewWriteTarget($this->root, $this->root . '/doomed'))->toBeFalse() + ->and(fileManagerIsNewWriteTarget($this->root, $this->root . '/linked'))->toBeFalse() + ->and(fileManagerIsNewWriteTarget($this->root, $this->root . '/'))->toBeFalse() + ->and(fileManagerIsNewWriteTarget($this->root, $this->root . '/..'))->toBeFalse(); +}); + +it('routes the classic manager create and duplicate actions through the new-target helpers', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php'); + + expect($source)->toContain('fileManagerIsNewWriteTarget($filemanager_path, $newdir)') + ->and($source)->toContain('fileManagerCreateFile($filemanager_path, $startpath . \'/\' . $filename)') + ->and($source)->toContain('fileManagerCopyToNewFile($filemanager_path, $filename, $newpath)') + ->and($source)->not->toContain('mkdirs($newdir') + ->and($source)->not->toContain('file_put_contents($startpath') + ->and($source)->not->toContain('copy($filename, $newpath)'); +}); + +it('prunes a media folder without following a nested folder symlink', function () { + fmLinksSymlink($this->tmp . '/outside', $this->root . '/doomed/inner/link'); + + expect(dir::prune($this->root . '/doomed', false))->toBeTrue() + ->and(file_exists($this->root . '/doomed'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('prunes only the link when the media folder is itself a symlink', function () { + fmLinksSymlink($this->tmp . '/outside', $this->root . '/linked'); + + expect(dir::prune($this->root . '/linked'))->toBeTrue() + ->and(is_link($this->root . '/linked'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('does not pick a dangling symlink as a free upload name', function () { + fmLinksSymlink($this->tmp . '/outside/shell.php', $this->root . '/upload.png'); + + expect(basename(file::getInexistantFilename('upload.png', $this->root)))->toBe('upload(1).png'); +}); + +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'); + $browser = fmLinksBrowser($this->root); + $method = new ReflectionMethod(browser::class, 'isNewUploadTarget'); + $method->setAccessible(true); + + expect($method->invoke($browser, $this->root . '/new.png'))->toBeTrue() + ->and($method->invoke($browser, $this->root . '/dangling.png'))->toBeFalse() + ->and($method->invoke($browser, $this->root . '/linked/new.png'))->toBeFalse() + ->and($method->invoke($browser, $this->root . '/doomed/inner/file.txt'))->toBeFalse() + ->and($method->invoke($browser, $this->tmp . '/outside/new.png'))->toBeFalse(); +}); + +it('checks the upload target before every write attempt', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/media/browser/mcpuk/core/browser.php'); + $start = strpos($source, 'protected function moveUploadFile('); + $body = substr($source, $start, strpos($source, 'protected function isNewUploadTarget(') - $start); + + expect(substr_count($body, '$this->isNewUploadTarget($target)'))->toBe(3); +}); + +it('tells links from plain entries, including dangling ones', function () { + fmLinksSymlink($this->tmp . '/outside', $this->root . '/linked'); + fmLinksSymlink($this->tmp . '/outside/missing.txt', $this->root . '/dangling.txt'); + + expect(fileManagerIsLink($this->root . '/doomed'))->toBeFalse() + ->and(fileManagerIsLink($this->root . '/doomed/inner/file.txt'))->toBeFalse() + ->and(fileManagerIsLink($this->root . '/not-there.txt'))->toBeFalse() + ->and(fileManagerIsLink($this->root . '/linked'))->toBeTrue() + ->and(fileManagerIsLink($this->root . '/dangling.txt'))->toBeTrue() + ->and(dir::isLink($this->root . '/doomed'))->toBeFalse() + ->and(dir::isLink($this->root . '/linked'))->toBeTrue() + ->and(dir::isLink($this->root . '/dangling.txt'))->toBeTrue(); +}); + +it('sees a Windows junction as a link, although is_link() does not', function () { + fmLinksJunction($this->tmp . '/outside', $this->root . '/junction'); + + expect(fileManagerIsLink($this->root . '/junction'))->toBeTrue() + ->and(dir::isLink($this->root . '/junction'))->toBeTrue() + ->and(fileManagerIsSafeWriteTarget($this->root, $this->root . '/junction/new.txt'))->toBeFalse() + ->and(fileManagerCreateFile($this->root, $this->root . '/junction/new.txt'))->toBeFalse() + ->and(file_exists($this->tmp . '/outside/new.txt'))->toBeFalse(); +}); + +it('deletes a folder tree without following a nested junction', function () { + fmLinksJunction($this->tmp . '/outside', $this->root . '/doomed/inner/junction'); + + expect(rrmdir($this->root . '/doomed'))->toBeTrue() + ->and(file_exists($this->root . '/doomed'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('deletes a folder tree holding a dangling junction', function () { + mkdir($this->tmp . '/gone'); + fmLinksJunction($this->tmp . '/gone', $this->root . '/doomed/inner/junction'); + rmdir($this->tmp . '/gone'); + + expect(rrmdir($this->root . '/doomed'))->toBeTrue() + ->and(file_exists($this->root . '/doomed'))->toBeFalse(); +}); + +it('prunes a media folder without following a nested junction', function () { + fmLinksJunction($this->tmp . '/outside', $this->root . '/doomed/inner/junction'); + + expect(dir::prune($this->root . '/doomed', false))->toBeTrue() + ->and(file_exists($this->root . '/doomed'))->toBeFalse() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('deletes a folder holding a symlink checked out as a plain file', function () { + // git with core.symlinks=false (the Windows default) writes the link target as the file content + file_put_contents($this->root . '/doomed/inner/link', '../../../outside'); + + expect(rrmdir($this->root . '/doomed'))->toBeTrue() + ->and(dir::prune($this->root, false))->toBeTrue() + ->and(file_get_contents($this->tmp . '/outside/keep.txt'))->toBe('outside'); +}); + +it('allows renaming to a new name only', function () { + $source = $this->root . '/doomed/inner/file.txt'; + file_put_contents($this->root . '/doomed/inner/other.txt', 'other'); + fmLinksSymlink($this->tmp . '/outside/keep.txt', $this->root . '/doomed/inner/keep.txt'); + + expect(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/inner/renamed.txt'))->toBeTrue() + ->and(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/inner/other.txt'))->toBeFalse() + ->and(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/inner/keep.txt'))->toBeFalse() + ->and(fileManagerCanRenameTo($this->root, $source, $this->tmp . '/outside/renamed.txt'))->toBeFalse(); +}); + +it('allows a rename that only changes the case of the name', function () { + $source = $this->root . '/doomed/inner/file.txt'; + + // on Windows the new name finds the file itself; elsewhere it is simply free + expect(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/inner/File.txt'))->toBeTrue() + ->and(fileManagerCanRenameTo($this->root, $source, $source))->toBeTrue(); +}); + +it('refuses a case-only rename onto a different file on a case-sensitive file system', function () { + file_put_contents($this->root . '/doomed/inner/File.txt', 'other'); + if (file_get_contents($this->root . '/doomed/inner/file.txt') !== 'inside') { + test()->markTestSkipped('the file system here ignores case'); + } + + expect(fileManagerCanRenameTo($this->root, $this->root . '/doomed/inner/file.txt', $this->root . '/doomed/inner/File.txt')) + ->toBeFalse(); +}); + +it('routes the classic manager file rename through the rename check', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php'); + + expect($source)->toContain('fileManagerCanRenameTo($filemanager_path, $filename, $path . \'/\' . $newFilename)'); +}); + +it('allows renaming a folder to a new name only', function () { + $source = $this->root . '/doomed/inner'; + mkdir($this->root . '/doomed/empty'); + fmLinksSymlink($this->tmp . '/outside', $this->root . '/doomed/linked'); + + expect(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/renamed'))->toBeTrue() + ->and(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/Inner'))->toBeTrue() + ->and(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/empty'))->toBeFalse() + ->and(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/linked'))->toBeFalse() + ->and(fileManagerCanRenameTo($this->root, $source, $this->root . '/doomed/'))->toBeFalse(); +}); + +it('refuses renaming a folder onto a junction', function () { + fmLinksJunction($this->tmp . '/outside', $this->root . '/doomed/junction'); + + expect(fileManagerCanRenameTo($this->root, $this->root . '/doomed/inner', $this->root . '/doomed/junction'))->toBeFalse(); +}); + +it('routes the classic manager folder rename through the rename check', function () { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/actions/files.dynamic.php'); + + expect($source)->toContain('fileManagerCanRenameTo($filemanager_path, $dirname, dirname($dirname) . \'/\' . $newDirname)'); +}); diff --git a/core/tests/Unit/Security/FileManagerWritePathChecksTest.php b/core/tests/Unit/Security/FileManagerWritePathChecksTest.php index 9acc79cc2f..ed165f8cc2 100644 --- a/core/tests/Unit/Security/FileManagerWritePathChecksTest.php +++ b/core/tests/Unit/Security/FileManagerWritePathChecksTest.php @@ -39,12 +39,12 @@ function runFileManagerWrite(string $site, string $function, array $request, arr $tmp = tempnam(sys_get_temp_dir(), 'evo-fm-'); file_put_contents($tmp, $script); try { - $output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($tmp) . ' 2>&1'); + $output = evoRunPhp($tmp); } finally { unlink($tmp); } - return (string) $output; + return $output; } beforeEach(function () { diff --git a/core/tests/Unit/SiteUrlFromHostHeaderTest.php b/core/tests/Unit/SiteUrlFromHostHeaderTest.php index a9982bf957..87857859db 100644 --- a/core/tests/Unit/SiteUrlFromHostHeaderTest.php +++ b/core/tests/Unit/SiteUrlFromHostHeaderTest.php @@ -33,14 +33,12 @@ function resolveSiteUrlInFreshProcess(array $server): string $scriptPath = tempnam(sys_get_temp_dir(), 'evo-site-url-') . '.php'; file_put_contents($scriptPath, $code); - $output = []; - $status = 0; - exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($scriptPath) . ' 2>&1', $output, $status); + $output = evoRunPhp($scriptPath, [], $status); @unlink($scriptPath); - expect($status)->toBe(0, implode("\n", $output)); + expect($status)->toBe(0, $output); - return trim(implode("\n", $output)); + return trim($output); } test('site url keeps the port the browser asked for', function (array $server, string $expected) { diff --git a/core/tests/Unit/TestProcessHelpersTest.php b/core/tests/Unit/TestProcessHelpersTest.php new file mode 100644 index 0000000000..189b014f4a --- /dev/null +++ b/core/tests/Unit/TestProcessHelpersTest.php @@ -0,0 +1,125 @@ +exec() do not count, as the tokenizer tells them apart. + * + * @return string[] "name(" per call, with its line + */ +function processHelperDirectCalls(string $source): array +{ + $tokens = array_values(array_filter( + token_get_all($source), + static fn ($token) => !is_array($token) || !in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true) + )); + $calls = []; + foreach ($tokens as $i => $token) { + if (!is_array($token) || !in_array($token[0], [T_STRING, T_NAME_FULLY_QUALIFIED], true) + || !in_array(strtolower(ltrim($token[1], '\\')), ['shell_exec', 'exec', 'passthru', 'system', 'popen'], true) + || ($tokens[$i + 1] ?? null) !== '(') { + continue; + } + $previous = $tokens[$i - 1] ?? null; + if (is_array($previous) && in_array($previous[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW], true)) { + continue; + } + $calls[] = $token[1] . '( on line ' . $token[2]; + } + + return $calls; +} + +it('tells direct process calls from methods, comments and strings', function () { + $source = 'exec("SQL"); + Foo::exec("static"); + new Filesystem(); + $s = "exec(inside a string)"; + function system_status() {} + $a = shell_exec("ls"); + exec("ls", $out); + \\passthru("ls");'; + + expect(processHelperDirectCalls($source))->toBe([ + 'shell_exec( on line 8', + 'exec( on line 9', + '\\passthru( on line 10', + ]); +}); + +it('returns the output and the exit code of a command', function () { + $script = processHelperScript('echo "first\nsecond\n"; exit(3);'); + try { + $output = evoRunCommand(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script), $exitCode); + } finally { + unlink($script); + } + + expect($output)->toBe("first\nsecond") + ->and($exitCode)->toBe(3); +}); + +it('passes each argument to the PHP script as one argument, however it looks', function () { + $script = processHelperScript('echo json_encode(array_slice($argv, 1));'); + $args = ['1', 'two words', 'it\'s', '$HOME & more', 'a|b;d']; + if (PHP_OS_FAMILY !== 'Windows') { + // escapeshellarg() on Windows turns " % ! into spaces, so they cannot travel there + $args[] = 'say "hi" 100% !'; + } + try { + $output = evoRunPhp($script, $args, $exitCode); + } finally { + unlink($script); + } + + expect(json_decode($output, true))->toBe($args) + ->and($exitCode)->toBe(0); +}); + +it('includes stderr in the output unless told not to', function () { + $script = processHelperScript('fwrite(STDERR, "to-stderr"); echo "to-stdout";'); + try { + $withStderr = evoRunPhp($script); + $withoutStderr = evoRunPhp($script, [], $exitCode, false); + } finally { + unlink($script); + } + + expect($withStderr)->toContain('to-stdout')->toContain('to-stderr') + ->and($withoutStderr)->toBe('to-stdout'); +}); + +it('leaves no direct shell_exec or exec calls in the tests', function () { + $offenders = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(dirname(__DIR__), FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php' || $file->getPathname() === __FILE__) { + continue; + } + foreach (processHelperDirectCalls(file_get_contents($file->getPathname())) as $call) { + $offenders[] = str_replace('\\', '/', substr($file->getPathname(), strlen(dirname(__DIR__)) + 1)) . ': ' . $call; + } + } + + // proc_open stays where a test needs processes that run alongside it (locks, parallel workers) + expect($offenders)->toBe([]); +}); diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php index 801cce9706..6fdc31cb5e 100755 --- a/manager/actions/files.dynamic.php +++ b/manager/actions/files.dynamic.php @@ -565,7 +565,8 @@ function renameFile(file) { $newdir = $startpath . '/' . $foldername; if (!$currentPathWritable) { echo '' . $_lang['files_access_denied'] . '

'; - } elseif (!mkdirs($newdir, 0777)) { + } elseif (!fileManagerIsNewWriteTarget($filemanager_path, $newdir) || !@mkdir($newdir, 0777)) { + // an existing name is refused: chmod below would follow a symlinked folder echo '', $_lang['file_folder_not_created'], '

'; } else { if (!@chmod($newdir, $newfolderaccessmode)) { @@ -591,8 +592,7 @@ function renameFile(file) { } elseif (preg_match('@(\\\\|\/|\:|\;|\,|\*|\?|\"|\<|\>|\||\?)@', $filename) !== 0) { echo $_lang['files.dynamic.php3']; } else { - $rs = file_put_contents($startpath . '/' . $filename, ''); - if ($rs === false) { + if (!fileManagerCreateFile($filemanager_path, $startpath . '/' . $filename)) { echo '', $_lang['file_folder_not_created'], '

'; } else { echo $_lang['files.dynamic.php4']; @@ -622,7 +622,7 @@ function renameFile(file) { } else { // Fix: Copy to same directory, not base path $newpath = dirname($filename) . '/' . $newFilename; - if (!copy($filename, $newpath)) { + if (!fileManagerCopyToNewFile($filemanager_path, $filename, $newpath)) { echo $_lang['files.dynamic.php5']; } umask($old_umask); @@ -650,6 +650,9 @@ function renameFile(file) { echo $_lang['files.dynamic.php3']; } elseif (fileManagerPathIsProtected(dirname($dirname) . '/' . $newDirname, $protected_path)) { echo '' . $_lang['files_access_denied'] . '

'; + } elseif (!fileManagerCanRenameTo($filemanager_path, $dirname, dirname($dirname) . '/' . $newDirname)) { + // on Linux rename() silently replaces an existing empty folder + echo '' . $_lang['files.dynamic.php6'] . '

'; } else if (!rename($dirname, dirname($dirname) . '/' . $newDirname)) { echo '', $_lang['file_folder_not_created'], '

'; } else { @@ -682,6 +685,9 @@ function renameFile(file) { echo '' . $_lang['files_filetype_notok'] . '

'; } elseif (preg_match('@(\\\\|\/|\:|\;|\,|\*|\?|\"|\<|\>|\||\?)@', $newFilename) !== 0) { echo $_lang['files.dynamic.php3']; + } elseif (!fileManagerCanRenameTo($filemanager_path, $filename, $path . '/' . $newFilename)) { + // renaming onto an existing name would silently replace that file + echo $_lang['files.dynamic.php6']; } else { if (!rename($filename, $path . '/' . $newFilename)) { echo $_lang['files.dynamic.php5']; diff --git a/manager/media/browser/mcpuk/core/browser.php b/manager/media/browser/mcpuk/core/browser.php index cb28134d6b..59f7dabf0a 100755 --- a/manager/media/browser/mcpuk/core/browser.php +++ b/manager/media/browser/mcpuk/core/browser.php @@ -973,9 +973,11 @@ protected function moveUploadFile($file, $dir) $filename = $this->normalizeFilename($file['name']); $target = "$dir/" . file::getInexistantFilename($filename, $dir); - if (!@move_uploaded_file($file['tmp_name'], $target) && - !@rename($file['tmp_name'], $target) && - !@copy($file['tmp_name'], $target) + // every attempt below writes through a symlink at $target, so check before each one + if (!$this->isNewUploadTarget($target) || + (!@move_uploaded_file($file['tmp_name'], $target) && + (!$this->isNewUploadTarget($target) || !@rename($file['tmp_name'], $target)) && + (!$this->isNewUploadTarget($target) || !@copy($file['tmp_name'], $target))) ) { @unlink($file['tmp_name']); @@ -997,6 +999,18 @@ protected function moveUploadFile($file, $dir) return $response; } + /** + * Whether an upload may be written to $target: nothing is there yet, not even a dangling + * symlink, and it lies inside the type folder. + * + * @param string $target + * @return bool + */ + protected function isNewUploadTarget($target) + { + return !file_exists($target) && !dir::isLink($target) && $this->isInsideTypeDir($target); + } + /** * @param null $file */ @@ -1210,7 +1224,7 @@ protected function isInsideTypeDir($absPath) $path = realpath($absPath); if ($path === false) { // nothing there yet is fine, a dangling link is not: writing to it creates its target - if (is_link($absPath)) { + if (dir::isLink($absPath)) { return false; } $parent = realpath(dirname($absPath)); diff --git a/manager/media/browser/mcpuk/lib/helper_dir.php b/manager/media/browser/mcpuk/lib/helper_dir.php index 38e3c2a2f1..4f1f2090a7 100755 --- a/manager/media/browser/mcpuk/lib/helper_dir.php +++ b/manager/media/browser/mcpuk/lib/helper_dir.php @@ -46,6 +46,14 @@ static function isWritable($dir) { static function prune(string $dir, bool $firstFailExit=true, array $failed=[]): mixed { + if (self::isLink($dir)) { + if (@unlink($dir) || @rmdir($dir)) + return true; + if ($firstFailExit) + return $dir; + $failed[] = $dir; + return $failed; + } $files = self::content($dir); if ($files === false) { if ($firstFailExit) @@ -55,7 +63,14 @@ static function prune(string $dir, bool $firstFailExit=true, array $failed=[]): } foreach ($files as $file) { - if (is_dir($file)) { + // a symlink goes as a link: pruning through it would empty the folder it points to + if (self::isLink($file)) { + if (!@unlink($file) && !@rmdir($file)) { + if ($firstFailExit) + return $file; + $failed[] = $file; + } + } elseif (is_dir($file)) { $failed_in = self::prune($file, $firstFailExit, $failed); if ($failed_in !== true) { if ($firstFailExit) @@ -81,6 +96,30 @@ static function prune(string $dir, bool $firstFailExit=true, array $failed=[]): return count($failed) ? $failed : true; } + /** Whether $path is a link of any kind: a symlink, or on Windows a junction or other + * reparse point, which is_link() does not report (PHP sees a junction as a plain folder). + * An existing entry that does not resolve to itself counts as one too. + * @param string $path + * @return bool */ + + static function isLink(string $path): bool + { + clearstatcache(true, $path); + if (is_link($path)) + return true; + // something is there that leads nowhere: a dangling junction, which only lstat() + // sees (is_link() above already caught a dangling symlink) + if (!file_exists($path)) + return PHP_OS_FAMILY === 'Windows' && @lstat($path) !== false; + $real = realpath($path); + $parent = realpath(dirname($path)); + if ($real === false || $parent === false) + return true; + $expected = rtrim(str_replace('\\', '/', $parent), '/') . '/' . basename($path); + $real = str_replace('\\', '/', $real); + return PHP_OS_FAMILY === 'Windows' ? strcasecmp($real, $expected) !== 0 : $real !== $expected; + } + /** Get the content of the given directory. Returns an array with filenames * or FALSE on failure * @param string $dir @@ -112,7 +151,8 @@ static function content(string $dir, array $options=[]) { $files = []; while (($file = @readdir($dh)) !== false) { - $type = filetype("$dir/$file"); + // a Windows junction has no type PHP knows ("unknown", plus a notice) + $type = @filetype("$dir/$file"); if ($options['followLinks'] && ($type === "link")) { $lfile = "$dir/$file"; @@ -121,7 +161,7 @@ static function content(string $dir, array $options=[]) { $lfile = @readlink($lfile); if (substr($lfile, 0, 1) != "/") $lfile = "$ldir/$lfile"; - $type = filetype($lfile); + $type = @filetype($lfile); } while ($type == "link"); } diff --git a/manager/media/browser/mcpuk/lib/helper_file.php b/manager/media/browser/mcpuk/lib/helper_file.php index fcc1f1aa92..8e511a1345 100755 --- a/manager/media/browser/mcpuk/lib/helper_file.php +++ b/manager/media/browser/mcpuk/lib/helper_file.php @@ -187,7 +187,8 @@ static function getInexistantFilename($filename, $dir=null, $tpl=null) { $tpl = str_replace('{name}', $name, $tpl); $tpl = str_replace('{ext}', (strlen($ext) ? ".$ext" : ""), $tpl); $i = 1; $file = "$dir/$filename"; - while (file_exists($file)) + // a dangling symlink counts as taken: writing to it would create its target + while (file_exists($file) || dir::isLink($file)) $file = "$dir/" . str_replace('{sufix}', $i++, $tpl); return $fullPath