[TikiWiki-commits] [Git][tikiwiki/tiki][30.x] [BP][FIX] wikiplugin_lsdir.php: prevent path traversal and XSS in LSDIR plugin
"Elifeleti Mukisa Dan \(@Danelif\) via TikiWiki-cvs" <[email protected]>
| Newsgroups | gmane.comp.cms.tiki.cvs |
|---|---|
| Message-ID | <6a10ac9acb2e1_3819ac5858263@gitlab-sidekiq-low-urgency-cpu-bound-v2-5755d7f9f9-lszq7.mail> |
Elifeleti Mukisa Dan pushed to branch 30.x at Tiki Wiki CMS Groupware / Tiki Commits: 85bde9c2 by Elifeleti Mukisa Dan at 2026-05-22T19:14:09+00:00 [BP][FIX] wikiplugin_lsdir.php: prevent path traversal and XSS in LSDIR plugin --- * [FIX] wikiplugin_lsdir.php: prevent path traversal and XSS in LSDIR plugin --- * [FIX] wikiplugin_lsdir.php: prevent path traversal and XSS in LSDIR plugin (cherry picked from commit 56e3de496be66e6be5980b57ff2eeae80bc26481) 91ee7e6f [FIX] Add missing security headers and enable previously disabled headers with safe default values bc349ffd [FIX] wikiplugin_lsdir.php: prevent path traversal and XSS in LSDIR plugin 101dd48c [FIX] wikiplugin_localfiles.php: prevent path traversal in LOCALFILES plugin 65cfcdde [ENH] Enhance path access restrictions for non-admin users Co-authored-by: Alfred Syatsukwa <[email protected]> See merge request tikiwiki/tiki!10295 (cherry picked from commit 3966df6b640b60d3e661e44f83c317c40e4691fd) 24fd3150 [FIX] tiki-send_newsletters.php: prevent insecure deserialization of... Co-authored-by: Elifeleti Mukisa Dan <[email protected]> See merge request tikiwiki/tiki!10317 - - - - - 5 changed files: - + lib/core/Tiki/WikiPlugin/FileaccessAllowlist.php - lib/prefs/wikiplugin.php - lib/prefslib.php - lib/wiki-plugins/wikiplugin_localfiles.php - lib/wiki-plugins/wikiplugin_lsdir.php Changes: ===================================== lib/core/Tiki/WikiPlugin/FileaccessAllowlist.php ===================================== @@ -0,0 +1,145 @@ +<?php + +// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project +// +// All Rights Reserved. See copyright.txt for details and a complete list of authors. +// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. +namespace Tiki\WikiPlugin; + +/** + * Allowlist enforcement for filesystem wiki plugins (LSDIR, LOCALFILES). + * + * Initialized from preference wikiplugin_fileaccess_allowed_paths (deny-by-default). + */ +class FileaccessAllowlist +{ + /** @var list<string> Canonical absolute roots */ + private array $allowedRoots; + + /** + * @param mixed $allowedPathsPreference Raw preference value (string CSV, serialized array, or array) + */ + public function __construct(mixed $allowedPathsPreference = null) + { + $this->allowedRoots = $this->buildAllowedRoots($allowedPathsPreference); + } + + public static function fromPreference(): self + { + return new self(\TikiLib::lib('tiki')->get_preference('wikiplugin_fileaccess_allowed_paths', '')); + } + + public function isConfigured(): bool + { + return $this->allowedRoots !== []; + } + + /** + * @param string $reason no_roots|outside_path|outside_dir + */ + public function getDeniedHtml(string $reason): string + { + $message = match ($reason) { + 'no_roots' => tra('Access denied: no allowed roots are configured. Ask your server administrator to set preference wikiplugin_fileaccess_allowed_paths.'), + 'outside_path' => tra('Access denied: path is outside allowed roots. Ask your server administrator to update preference wikiplugin_fileaccess_allowed_paths.'), + 'outside_dir' => tra('Access denied: directory is outside allowed roots. Ask your server administrator to update preference wikiplugin_fileaccess_allowed_paths.'), + default => tra('Access denied.'), + }; + + return "<span class='attention'>" . $message . '</span>'; + } + + /** + * @return string|false Canonical path when allowed, false otherwise + */ + public function resolvePath(string $requestedPath): string|false + { + if ($this->allowedRoots === []) { + return false; + } + + $resolvedPath = realpath($requestedPath); + if ($resolvedPath === false || ! $this->isWithinAllowedRoots($resolvedPath)) { + return false; + } + + return $resolvedPath; + } + + /** + * @param list<string> $candidates Paths to try (e.g. relative and DOCUMENT_ROOT-prefixed) + * @return string|false Canonical path when allowed, false otherwise + */ + public function resolvePathFromCandidates(array $candidates): string|false + { + if ($this->allowedRoots === []) { + return false; + } + + foreach ($candidates as $candidate) { + if (! is_string($candidate) || $candidate === '') { + continue; + } + + $resolved = $this->resolvePath($candidate); + if ($resolved !== false) { + return $resolved; + } + } + + return false; + } + + /** + * @return list<string> Path strings from preference (not yet canonicalized) + */ + private function parsePreferenceValue(mixed $allowedPathsPreference): array + { + if (is_array($allowedPathsPreference)) { + return $allowedPathsPreference; + } + + if (! is_string($allowedPathsPreference) || $allowedPathsPreference === '') { + return []; + } + + $unserialized = @unserialize($allowedPathsPreference, ['allowed_classes' => false]); + if (is_array($unserialized)) { + return $unserialized; + } + + return preg_split('/\s*,\s*/', $allowedPathsPreference, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * @return list<string> + */ + private function buildAllowedRoots(mixed $allowedPathsPreference): array + { + $allowedRoots = []; + foreach ($this->parsePreferenceValue($allowedPathsPreference) as $root) { + if (! is_string($root) || $root === '') { + continue; + } + + $resolvedRoot = realpath($root); + if ($resolvedRoot !== false) { + $allowedRoots[] = rtrim($resolvedRoot, '/\\'); + } + } + + return array_values(array_unique($allowedRoots)); + } + + private function isWithinAllowedRoots(string $path): bool + { + foreach ($this->allowedRoots as $root) { + $rootWithSep = $root . DIRECTORY_SEPARATOR; + if ($path === $root || str_starts_with($path, $rootWithSep)) { + return true; + } + } + + return false; + } +} ===================================== lib/prefs/wikiplugin.php ===================================== @@ -196,5 +196,16 @@ function prefs_wikiplugin_list($partial = false) 'warning' => tr('Setting this to a higher value than the default of 500 may have performance implications.'), ]; + $prefs['wikiplugin_fileaccess_allowed_paths'] = [ + 'name' => tr('Allowed filesystem roots for file-access wiki plugins'), + 'description' => tr('Comma-separated allowlist of absolute filesystem roots for LSDIR and LOCALFILES plugins. Access is denied by default when empty.'), + 'type' => 'text', + 'separator' => ',', + 'default' => '', + 'tags' => ['advanced'], + 'warning' => tr('Security-sensitive setting. Keep this list minimal and avoid allowing the Tiki installation directory.'), + 'hint' => tr('Set this using system configuration (tiki.ini/local.ini) so it can be forced by host policy.'), + ]; + return $prefs; } ===================================== lib/prefslib.php ===================================== @@ -20,6 +20,7 @@ class PreferencesLib 'feature_create_webhelp', 'scheduler_shell_command', 'smarty_enable_string_eval', + 'wikiplugin_fileaccess_allowed_paths', ]; private $data = []; ===================================== lib/wiki-plugins/wikiplugin_localfiles.php ===================================== @@ -9,7 +9,7 @@ function wikiplugin_localfiles_info() return [ 'name' => tra('Local Files'), 'documentation' => 'PluginLocalFiles', - 'description' => tra('Show a link to local or shared files and directories.'), + 'description' => tra('Show a link to local files and directories. Access is denied unless system preference wikiplugin_fileaccess_allowed_paths defines allowed base paths.'), 'prefs' => ['wikiplugin_localfiles'], 'iconname' => 'file', 'introduced' => 12, @@ -20,7 +20,7 @@ function wikiplugin_localfiles_info() 'path' => [ 'required' => false, 'name' => tra('Path'), - 'description' => tra('Local file or directory path'), + 'description' => tra('Absolute path to a local file or directory. Must be within one of the allowed base paths configured in preference wikiplugin_fileaccess_allowed_paths.'), 'since' => '12.0', 'default' => '', 'filter' => 'text', @@ -28,7 +28,7 @@ function wikiplugin_localfiles_info() 'list' => [ 'required' => false, 'name' => tra('List Directory'), - 'description' => tra('If the path above is a directory then list the contents.'), + 'description' => tra('If the path above is a directory, list its contents. The entries "." and ".." are always excluded.'), 'since' => '12.0', 'filter' => 'alpha', 'default' => 'n', @@ -61,9 +61,28 @@ function wikiplugin_localfiles($data, $params) // TODO refactor: defaults for plugins? $smartylib = TikiLib::lib('smarty'); $files = []; + + $fileaccess = \Tiki\WikiPlugin\FileaccessAllowlist::fromPreference(); + + if (! $fileaccess->isConfigured()) { + return $fileaccess->getDeniedHtml('no_roots'); + } + + $requestedPath = $params['path'] ?? ''; + $resolvedPath = $fileaccess->resolvePath($requestedPath); + + if ($resolvedPath === false) { + return $fileaccess->getDeniedHtml('outside_path'); + } + $params['path'] = $resolvedPath; + if (! is_array($params['path'])) { if ($params['list'] === 'y' && file_exists($params['path']) && is_dir($params['path'])) { - $params['path'] = scandir($params['path']); + // Filter out . and .. to avoid exposing parent-directory entries. + $params['path'] = array_values(array_filter( + scandir($params['path']), + fn($entry) => $entry !== '.' && $entry !== '..' + )); } else { $params['path'] = [$params['path']]; } ===================================== lib/wiki-plugins/wikiplugin_lsdir.php ===================================== @@ -9,7 +9,7 @@ function wikiplugin_lsdir_info() return [ 'name' => tra('List Directory'), 'documentation' => 'PluginLsDir', - 'description' => tra('List files in a directory'), + 'description' => tra('List files in a directory. Access is denied unless system preference wikiplugin_fileaccess_allowed_paths defines allowed base paths.'), 'prefs' => [ 'wikiplugin_lsdir' ], 'validate' => 'all', 'iconname' => 'file-archive', @@ -18,7 +18,7 @@ function wikiplugin_lsdir_info() 'dir' => [ 'required' => true, 'name' => tra('Directory'), - 'description' => tra('Full path to the server-local directory. Default is the document root.'), + 'description' => tra('Path to a server-local directory. Must be within one of the allowed base paths configured in preference wikiplugin_fileaccess_allowed_paths.'), 'since' => '1', 'default' => '', ], @@ -79,24 +79,24 @@ function wikiplugin_lsdir($data, $params) extract($params, EXTR_SKIP); - // make sure document_root has no trailing slash - if (! empty($_SERVER['DOCUMENT_ROOT'])) { - $tail = strlen($_SERVER['DOCUMENT_ROOT']) - 1; - if (substr($_SERVER['DOCUMENT_ROOT'], $tail) == '/') { - $pathprefix = substr($_SERVER['DOCUMENT_ROOT'], 0, $tail); - } else { - $pathprefix = $_SERVER['DOCUMENT_ROOT']; - } + $fileaccess = \Tiki\WikiPlugin\FileaccessAllowlist::fromPreference(); + + if (! $fileaccess->isConfigured()) { + return $fileaccess->getDeniedHtml('no_roots'); } - // make sure dir has starting slash - if (! empty($dir)) { - if (! str_starts_with($dir, '/')) { - $dir = '/' . $dir; - } + $dirCandidates = [$dir]; + if (! str_starts_with($dir, '/') && ! empty($_SERVER['DOCUMENT_ROOT'])) { + $dirCandidates[] = rtrim($_SERVER['DOCUMENT_ROOT'], '/\\') . '/' . $dir; + } + + $resolvedAllowedDir = $fileaccess->resolvePathFromCandidates($dirCandidates); + + if ($resolvedAllowedDir === false) { + return $fileaccess->getDeniedHtml('outside_dir'); } - $dir = $pathprefix . $dir; + $dir = $resolvedAllowedDir; // make sure urlprefix has a trailing slash if (! empty($urlprefix)) { @@ -127,7 +127,7 @@ function wikiplugin_lsdir($data, $params) $dh = @opendir($dir); if (! $dh) { - $error = "<span class='attention'><b>$dir</b> " . tra("could not be opened because it doesn't exist or permission was denied") . "</span>"; + $error = "<span class='attention'><b>" . htmlspecialchars($dir, ENT_QUOTES, 'UTF-8') . '</b> ' . tra("could not be opened because it doesn't exist or permission was denied") . '</span>'; return $error; } @@ -157,9 +157,11 @@ function wikiplugin_lsdir($data, $params) break 1; } if (! empty($urlprefix)) { - $ret .= "<a href='$urlprefix$filename' class='wiki'>$filename</a><br />"; + $safeUrl = htmlspecialchars($urlprefix . $filename, ENT_QUOTES, 'UTF-8'); + $safeName = htmlspecialchars($filename, ENT_QUOTES, 'UTF-8'); + $ret .= "<a href='$safeUrl' class='wiki'>$safeName</a><br />"; } else { - $ret .= "$filename<br />"; + $ret .= htmlspecialchars($filename, ENT_QUOTES, 'UTF-8') . '<br />'; } if ($limit > 0) { $count++; View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/85bde9c26afbe616f78977930a08bfc589c57fd4 -- View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/85bde9c26afbe616f78977930a08bfc589c57fd4 You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help _______________________________________________ TikiWiki-cvs mailing list [email protected] https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs