[TikiWiki-commits] [Git][tikiwiki/tiki][27.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 <6a10e7ef2f25f_38192a80708b4@gitlab-sidekiq-low-urgency-cpu-bound-v2-d7f87744c-mxskf.mail>

Elifeleti Mukisa Dan pushed to branch 27.x at Tiki Wiki CMS Groupware / Tiki


Commits:
63861fd4 by Elifeleti Mukisa Dan at 2026-05-22T23:27:46+00:00
[BP][FIX] wikiplugin_lsdir.php: prevent path traversal and XSS in LSDIR plugin
---
* [BP][FIX] wikiplugin_lsdir.php: prevent path traversal and XSS in LSDIR plugin
---
* [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

See merge request tikiwiki/tiki!10329

See merge request tikiwiki/tiki!10342

- - - - -


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
=====================================
@@ -22,6 +22,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',
@@ -67,9 +67,28 @@ function wikiplugin_localfiles($data, $params)
     }
     $params = array_merge($defaults, $params);
     $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' => '',
             ],
@@ -84,24 +84,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 (substr($dir, 0, 1) != '/') {
-            $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)) {
@@ -132,7 +132,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;
     }
 
@@ -162,9 +162,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/63861fd4e14f758d26a925d5e65f4de457983df1

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/63861fd4e14f758d26a925d5e65f4de457983df1
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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.