PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.1
2.2.1 2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 All 44 releases
← All changes | app/Services/Libs/FileSystem.php +183 -52 2.0.1 → 2.2.1 View file →
@@ -2,8 +2,10 @@
2 2
3 3 namespace FluentBoards\App\Services\Libs;
4 4
5 5 use FluentBoards\Framework\Support\Arr;
6 +use FluentBoards\App\Services\UploadService;
7 +use Exception;
6 8
7 9 class FileSystem
8 10 {
9 11 protected $subDir = '';
@@ -20,11 +22,8 @@
20 22 public function _get($file)
21 23 {
22 24 $arr = explode('/', $file);
23 25 $fileName = end($arr);
24 - if ($this->subDir) {
25 - $fileName = $this->subDir . DIRECTORY_SEPARATOR . $fileName;
26 - }
27 26
28 27 $filePath = $this->getDir() . DIRECTORY_SEPARATOR . $fileName;
29 28
30 29 // Use WordPress Filesystem API
@@ -54,8 +53,62 @@
54 53 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
55 54 }
56 55
57 56 /**
57 + * Legacy Core/Pro entry point: prepare storage, not a verified security boundary.
58 + * Rule writes are best effort; $allowProbe is retained for caller compatibility.
59 + */
60 + public function _installUploadProtectionRules($allowProbe = true)
61 + {
62 + $directory = (new static())->_getDir();
63 + if (!wp_mkdir_p($directory)) {
64 + return false;
65 + }
66 + $path = $directory . '/.htaccess';
67 + if (!$this->hasUploadProtectionRules($path)) {
68 + if (!function_exists('insert_with_markers')) {
69 + require_once ABSPATH . 'wp-admin/includes/misc.php';
70 + }
71 + insert_with_markers($path, 'Fluent Boards', $this->uploadProtectionRules());
72 + }
73 + return true;
74 + }
75 +
76 + protected function uploadProtectionRules()
77 + {
78 + return [
79 + '<IfModule mod_authz_core.c>',
80 + 'Require all denied',
81 + '</IfModule>',
82 + '<IfModule !mod_authz_core.c>',
83 + 'Order deny,allow',
84 + 'Deny from all',
85 + '</IfModule>',
86 + ];
87 + }
88 +
89 + protected function hasUploadProtectionRules($path)
90 + {
91 + if (!is_file($path) || !is_readable($path)) {
92 + return false;
93 + }
94 + $contents = str_replace("\r\n", "\n", file_get_contents($path));
95 + if (preg_match_all('/^# BEGIN Fluent Boards\n(.*?)^# END Fluent Boards$/ms', $contents, $blocks) !== 1) {
96 + return false;
97 + }
98 + $rules = array_values(array_filter(array_map('trim', explode("\n", $blocks[1][0])), function ($line) {
99 + return $line !== '' && strpos($line, '#') !== 0;
100 + }));
101 + return $rules === $this->uploadProtectionRules();
102 + }
103 +
104 + /** Legacy adapter for older Pro callers; protection is no longer probed over HTTP. */
105 + public function _checkUploadProtection($allowProbe = true)
106 + {
107 + return null;
108 + }
109 +
110 + /**
58 111 * Get absolute path of file using custom upload dir name of this application
59 112 * @return string [file path]
60 113 */
61 114 public function _getAbsolutePathOfFile($file)
@@ -63,9 +116,79 @@
63 116 return $this->_getDir() . DIRECTORY_SEPARATOR . $file;
64 117 }
65 118
66 119 /**
67 - * Upload files into custom upload dir of this application
120 + * Resolve a stored attachment path only when it belongs to Fluent Boards uploads.
121 + *
122 + * @param string $storedPath
123 + * @param int|null $boardId
124 + * @return string|null
125 + */
126 + public function _resolveLocalAttachmentPath($storedPath, $boardId = null)
127 + {
128 + if (!$storedPath) {
129 + return null;
130 + }
131 +
132 + $storedFilename = rawurldecode((string) $storedPath);
133 + $isBareFilename = $storedFilename !== ''
134 + && strpos($storedFilename, '/') === false
135 + && strpos($storedFilename, '\\') === false;
136 +
137 + if ($isBareFilename && $boardId === null) {
138 + return null;
139 + }
140 +
141 + $filePath = $isBareFilename ? null : realpath($storedPath);
142 + $allowedDirectory = $this->_getDir();
143 + $pluginRoot = realpath($allowedDirectory);
144 +
145 + if (!$pluginRoot) {
146 + return null;
147 + }
148 +
149 + $pluginRoot = rtrim($pluginRoot, DIRECTORY_SEPARATOR);
150 + $allowedRoot = $pluginRoot;
151 +
152 + if ($boardId !== null) {
153 + if (!is_int($boardId) && !is_string($boardId)) {
154 + return null;
155 + }
156 +
157 + $boardId = filter_var($boardId, FILTER_VALIDATE_INT, [
158 + 'options' => ['min_range' => 1],
159 + ]);
160 + if ($boardId === false) {
161 + return null;
162 + }
163 +
164 + $allowedDirectory .= DIRECTORY_SEPARATOR . 'board_' . $boardId;
165 + if (is_link($allowedDirectory)) {
166 + return null;
167 + }
168 +
169 + $allowedRoot = realpath($allowedDirectory);
170 + $expectedBoardRoot = $pluginRoot . DIRECTORY_SEPARATOR . 'board_' . $boardId;
171 + if (!$allowedRoot || $allowedRoot !== $expectedBoardRoot) {
172 + return null;
173 + }
174 + }
175 +
176 + if ($isBareFilename) {
177 + $filePath = realpath($allowedDirectory . DIRECTORY_SEPARATOR . $storedFilename);
178 + }
179 +
180 + if (!$filePath || !$allowedRoot || !is_file($filePath)) {
181 + return null;
182 + }
183 +
184 + $allowedRoot = rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
185 +
186 + return strpos($filePath, $allowedRoot) === 0 ? $filePath : null;
187 + }
188 +
189 + /**
190 + * Upload request files into the application's custom upload directory.
68 191 * @return array
69 192 */
70 193 public function _uploadFromRequest()
71 194 {
@@ -72,45 +195,59 @@
72 195 return $this->_put(FluentBoards('request')->files());
73 196 }
74 197
75 198 /**
76 - * Upload files into custom upload dir of this application
199 + * Prepare upload-directory protection before accepting local files.
77 200 * @param array $files
78 201 * @return array
79 202 */
80 203 public function _put($files)
81 204 {
205 + if (!$this->_installUploadProtectionRules()) {
206 + throw new Exception(__('Could not create the upload directory. Please check directory permissions.', 'fluent-boards'));
207 + }
208 +
82 209 if (!function_exists('wp_handle_upload')) {
83 210 require_once(ABSPATH . 'wp-admin/includes/file.php');
84 211 }
85 212
86 - $this->overrideUploadDir();
87 -
88 213 $uploadOverrides = [
89 214 'test_form' => false,
90 - // Accept the same allow-list FluentBoards validates against, so wp_handle_upload
91 - // doesn't reject broader types (e.g. .json, .md) by extension.
92 - 'mimes' => \FluentBoards\App\Services\UploadService::getAllowedMimeMap(),
215 + 'mimes' => UploadService::getAllowedMimeMap(),
93 216 ];
94 - $uploadedFiles = []; // Initialize the array
217 + $uploadedFiles = [];
218 + $files = is_object($files) ? [$files] : (array) $files;
95 219
96 - if(is_object($files)) {
97 - $files = [$files];
98 - }
220 + $this->_overrideUploadDir();
221 + try {
222 + foreach ($files as $file) {
223 + $filesArray = $file->toArray();
224 + $filesArray['tmp_name'] = $file->getRealPath();
225 + $extraData = Arr::only($filesArray, ['name', 'size']);
226 + if (empty($filesArray['error']) && is_file($filesArray['tmp_name'])) {
227 + $filesArray['size'] = filesize($filesArray['tmp_name']);
228 + $filesArray['size_in_bytes'] = $filesArray['size'];
229 + (new UploadService())->validateFile($filesArray);
230 + }
231 + $uploadsData = wp_handle_upload($filesArray, $uploadOverrides);
232 + if (!empty($uploadsData['error']) || empty($uploadsData['file'])) {
233 + throw new Exception($uploadsData['error'] ?? __('File upload failed.', 'fluent-boards'));
234 + }
99 235
100 - foreach ((array)$files as $file) {
101 -
102 - $filesArray = $file->toArray();
103 -
104 - // tmp_name is the path to the uploaded file which is required for wp_handle_upload, new framework update
105 - // changed the way to get the tmp_name Reference to line # 448 in File.php
106 - $filesArray['tmp_name'] = $file->getRealPath();
107 -
108 - $extraData = Arr::only($filesArray, ['name', 'size']);
109 - $uploadsData = \wp_handle_upload($filesArray, $uploadOverrides);
110 - // Add the full path to the file
111 - $uploadsData['full_path'] = $this->_getAbsolutePathOfFile($uploadsData['file']);
112 - $uploadedFiles[] = array_merge($extraData, $uploadsData);
236 + // Keep WordPress's actual path; callers also expect a legacy bare filename.
237 + $uploadsData['full_path'] = $uploadsData['file'];
238 + $uploadsData['file'] = basename($uploadsData['file']);
239 + $uploadedFiles[] = array_merge($extraData, $uploadsData);
240 + }
241 + } catch (Exception $exception) {
242 + // The batch fails as a whole, so remove files this call already stored.
243 + foreach ($uploadedFiles as $uploadedFile) {
244 + wp_delete_file($uploadedFile['full_path']);
245 + }
246 + throw $exception;
247 + } finally {
248 + remove_filter('wp_handle_upload_prefilter', [$this, '_renameFileName']);
249 + remove_filter('upload_dir', [$this, '_setCustomUploadDir']);
113 250 }
114 251
115 252 return $uploadedFiles;
116 253 }
@@ -138,19 +275,10 @@
138 275 * Register filters for custom upload dir
139 276 */
140 277 public function _overrideUploadDir()
141 278 {
142 - add_filter('wp_handle_upload_prefilter', function ($file) {
143 - add_filter('upload_dir', [$this, '_setCustomUploadDir']);
144 -
145 - add_filter('wp_handle_upload', function ($fileinfo) {
146 - remove_filter('upload_dir', [$this, '_setCustomUploadDir']);
147 - $fileinfo['file'] = basename($fileinfo['file']);
148 - return $fileinfo;
149 - });
150 -
151 - return $this->_renameFileName($file);
152 - });
279 + add_filter('wp_handle_upload_prefilter', [$this, '_renameFileName']);
280 + add_filter('upload_dir', [$this, '_setCustomUploadDir']);
153 281 }
154 282
155 283 /**
156 284 * Set plugin's custom upload dir
@@ -161,19 +289,17 @@
161 289 {
162 290 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
163 291
164 292 if ($this->subDir) {
165 - $fbsUploadDir .= DIRECTORY_SEPARATOR . $this->subDir;
166 - if (!is_dir($param['basedir'] . $fbsUploadDir)) {
167 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Creating custom upload subdirectory during upload process, WP_Filesystem not initialized
168 - @mkdir($param['basedir'] . $fbsUploadDir, 0755);
169 - }
293 + $fbsUploadDir .= '/' . $this->subDir;
170 294 }
171 295
172 - $param['url'] = $param['baseurl'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
296 + $param['url'] = trailingslashit($param['baseurl']) . trim($fbsUploadDir, '/');
297 + $param['path'] = trailingslashit($param['basedir']) . trim($fbsUploadDir, '/');
298 + if (!wp_mkdir_p($param['path'])) {
299 + $param['error'] = __('Could not create the upload directory.', 'fluent-boards');
300 + }
173 301
174 - $param['path'] = $param['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
175 -
176 302 return $param;
177 303 }
178 304
179 305 /**
@@ -182,10 +308,9 @@
182 308 * @return array $file
183 309 */
184 310 public function _renameFileName($file)
185 311 {
186 - $currentTimeStamp = (new \DateTimeImmutable())->getTimestamp();
187 - $prefix = $currentTimeStamp . '-';
312 + $prefix = wp_generate_uuid4() . '-';
188 313 $prefix = apply_filters('fluent_boards/uploaded_file_name_prefix', $prefix);
189 314 $file['name'] = $prefix . $file['name'];
190 315
191 316 return $file;
@@ -192,12 +317,18 @@
192 317 }
193 318
194 319 public function _deleteDir($dir)
195 320 {
196 - $dir = $this->getDir() . DIRECTORY_SEPARATOR . $dir;
197 - if (is_dir($dir)) {
198 - $this->deleteContents($dir);
321 + $root = realpath($this->_getDir());
322 + $path = $root ? $root . DIRECTORY_SEPARATOR . $dir : '';
323 + $resolved = $path ? realpath($path) : false;
324 + // Never remove the storage root, escape it, or follow a directory symlink.
325 + if (!$root || !$resolved || is_link($path)
326 + || strpos($resolved, $root . DIRECTORY_SEPARATOR) !== 0) {
327 + return false;
199 328 }
329 +
330 + return is_dir($resolved) ? $this->deleteContents($resolved) : false;
200 331 }
201 332
202 333 /**
203 334 * Delete a directory and its content
@@ -208,9 +339,9 @@
208 339 private function deleteContents($dir)
209 340 {
210 341 $files = array_diff(scandir($dir), ['.', '..']);
211 342 foreach ($files as $file) {
212 - if(is_dir("$dir/$file")) {
343 + if (!is_link("$dir/$file") && is_dir("$dir/$file")) {
213 344 // Recursively delete subdirectory
214 345 $this->deleteContents("$dir/$file");
215 346 } else {
216 347 // Delete file