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..789590611b 100644
--- a/core/functions/actions/files.php
+++ b/core/functions/actions/files.php
@@ -160,6 +160,325 @@ 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('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
+ * 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 (fileManagerIsLink($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('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
+ * 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,61 +938,46 @@ 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;
}
}
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);
}
}
@@ -683,6 +987,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 +1000,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 +1131,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 +1168,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/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/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/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/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/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
new file mode 100644
index 0000000000..ed165f8cc2
--- /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 = evoRunPhp($tmp);
+ } finally {
+ unlink($tmp);
+ }
+
+ return $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/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/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..6fdc31cb5e 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'] . '
';
@@ -640,7 +565,8 @@ function safe_unzip($file, $path) {
$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)) {
@@ -666,8 +592,7 @@ function safe_unzip($file, $path) {
} 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'];
@@ -697,7 +622,7 @@ function safe_unzip($file, $path) {
} 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);
@@ -715,12 +640,19 @@ 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'] . '
';
+ } 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 {
@@ -753,6 +685,9 @@ function safe_unzip($file, $path) {
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 aa61c90892..59f7dabf0a 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']}");
}
@@ -972,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']);
@@ -996,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
*/
@@ -1168,7 +1183,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 +1200,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 (dir::isLink($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 +1277,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 +1351,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 +1393,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 +1405,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 +1425,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 +1458,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 === ''
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