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 +202 -42 1.31 → 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,14 +22,19 @@
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 + $filePath = $this->getDir() . DIRECTORY_SEPARATOR . $fileName;
28 +
29 + // Use WordPress Filesystem API
30 + global $wp_filesystem;
31 + if (!function_exists('WP_Filesystem')) {
32 + require_once(ABSPATH . 'wp-admin/includes/file.php');
26 33 }
27 - return file_get_contents(
28 - $this->getDir() . DIRECTORY_SEPARATOR . $fileName
29 - );
34 + WP_Filesystem();
35 +
36 + return $wp_filesystem->get_contents($filePath);
30 37 }
31 38
32 39 /**
33 40 * Get custom upload dir name of this application
@@ -46,8 +53,62 @@
46 53 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
47 54 }
48 55
49 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 + /**
50 111 * Get absolute path of file using custom upload dir name of this application
51 112 * @return string [file path]
52 113 */
53 114 public function _getAbsolutePathOfFile($file)
@@ -55,9 +116,79 @@
55 116 return $this->_getDir() . DIRECTORY_SEPARATOR . $file;
56 117 }
57 118
58 119 /**
59 - * 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.
60 191 * @return array
61 192 */
62 193 public function _uploadFromRequest()
63 194 {
@@ -64,30 +195,59 @@
64 195 return $this->_put(FluentBoards('request')->files());
65 196 }
66 197
67 198 /**
68 - * Upload files into custom upload dir of this application
199 + * Prepare upload-directory protection before accepting local files.
69 200 * @param array $files
70 201 * @return array
71 202 */
72 203 public function _put($files)
73 204 {
205 + if (!$this->_installUploadProtectionRules()) {
206 + throw new Exception(__('Could not create the upload directory. Please check directory permissions.', 'fluent-boards'));
207 + }
208 +
74 209 if (!function_exists('wp_handle_upload')) {
75 210 require_once(ABSPATH . 'wp-admin/includes/file.php');
76 211 }
77 212
78 - $this->overrideUploadDir();
213 + $uploadOverrides = [
214 + 'test_form' => false,
215 + 'mimes' => UploadService::getAllowedMimeMap(),
216 + ];
217 + $uploadedFiles = [];
218 + $files = is_object($files) ? [$files] : (array) $files;
79 219
80 - $uploadOverrides = ['test_form' => false];
81 - $uploadedFiles = []; // Initialize the array
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 + }
82 235
83 - foreach ((array)$files as $file) {
84 - $filesArray = $file->toArray();
85 - $extraData = Arr::only($filesArray, ['name', 'size']);
86 - $uploadsData = \wp_handle_upload($filesArray, $uploadOverrides);
87 - // Add the full path to the file
88 - $uploadsData['full_path'] = $this->_getAbsolutePathOfFile($uploadsData['file']);
89 - $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']);
90 250 }
91 251
92 252 return $uploadedFiles;
93 253 }
@@ -103,9 +263,12 @@
103 263
104 264 foreach ($files as $file) {
105 265 $arr = explode('/', $file);
106 266 $fileName = end($arr);
107 - @unlink($this->getDir() . '/' . $fileName);
267 + $filePath = $this->getDir() . '/' . $fileName;
268 + if (file_exists($filePath)) {
269 + wp_delete_file($filePath);
270 + }
108 271 }
109 272 }
110 273
111 274 /**
@@ -112,19 +275,10 @@
112 275 * Register filters for custom upload dir
113 276 */
114 277 public function _overrideUploadDir()
115 278 {
116 - add_filter('wp_handle_upload_prefilter', function ($file) {
117 - add_filter('upload_dir', [$this, '_setCustomUploadDir']);
118 -
119 - add_filter('wp_handle_upload', function ($fileinfo) {
120 - remove_filter('upload_dir', [$this, '_setCustomUploadDir']);
121 - $fileinfo['file'] = basename($fileinfo['file']);
122 - return $fileinfo;
123 - });
124 -
125 - return $this->_renameFileName($file);
126 - });
279 + add_filter('wp_handle_upload_prefilter', [$this, '_renameFileName']);
280 + add_filter('upload_dir', [$this, '_setCustomUploadDir']);
127 281 }
128 282
129 283 /**
130 284 * Set plugin's custom upload dir
@@ -135,18 +289,17 @@
135 289 {
136 290 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
137 291
138 292 if ($this->subDir) {
139 - $fbsUploadDir .= DIRECTORY_SEPARATOR . $this->subDir;
140 - if (!is_dir($param['basedir'] . $fbsUploadDir)) {
141 - @mkdir($param['basedir'] . $fbsUploadDir, 0755);
142 - }
293 + $fbsUploadDir .= '/' . $this->subDir;
143 294 }
144 295
145 - $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 + }
146 301
147 - $param['path'] = $param['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
148 -
149 302 return $param;
150 303 }
151 304
152 305 /**
@@ -155,10 +308,9 @@
155 308 * @return array $file
156 309 */
157 310 public function _renameFileName($file)
158 311 {
159 - $currentTimeStamp = (new \DateTimeImmutable())->getTimestamp();
160 - $prefix = $currentTimeStamp . '-';
312 + $prefix = wp_generate_uuid4() . '-';
161 313 $prefix = apply_filters('fluent_boards/uploaded_file_name_prefix', $prefix);
162 314 $file['name'] = $prefix . $file['name'];
163 315
164 316 return $file;
@@ -165,12 +317,18 @@
165 317 }
166 318
167 319 public function _deleteDir($dir)
168 320 {
169 - $dir = $this->getDir() . DIRECTORY_SEPARATOR . $dir;
170 - if (is_dir($dir)) {
171 - $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;
172 328 }
329 +
330 + return is_dir($resolved) ? $this->deleteContents($resolved) : false;
173 331 }
174 332
175 333 /**
176 334 * Delete a directory and its content
@@ -181,17 +339,19 @@
181 339 private function deleteContents($dir)
182 340 {
183 341 $files = array_diff(scandir($dir), ['.', '..']);
184 342 foreach ($files as $file) {
185 - if(is_dir("$dir/$file")) {
343 + if (!is_link("$dir/$file") && is_dir("$dir/$file")) {
186 344 // Recursively delete subdirectory
187 345 $this->deleteContents("$dir/$file");
188 346 } else {
189 347 // Delete file
348 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Recursive directory cleanup, direct file operations required
190 349 unlink("$dir/$file");
191 350 }
192 351 }
193 352 // Delete the directory itself. If the directory is not empty, it will not be deleted.
353 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Recursive directory removal, WP_Filesystem not suitable for this operation
194 354 return rmdir($dir);
195 355 }
196 356
197 357 public static function __callStatic($method, $params)