PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
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 +255 -9 1.11 → 2.2.0 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\Constant;
7 +use Exception;
6 8
7 9 class FileSystem
8 10 {
9 11 protected $subDir = '';
@@ -23,11 +25,19 @@
23 25 $fileName = end($arr);
24 26 if ($this->subDir) {
25 27 $fileName = $this->subDir . DIRECTORY_SEPARATOR . $fileName;
26 28 }
27 - return file_get_contents(
28 - $this->getDir() . DIRECTORY_SEPARATOR . $fileName
29 - );
29 +
30 + $filePath = $this->getDir() . DIRECTORY_SEPARATOR . $fileName;
31 +
32 + // Use WordPress Filesystem API
33 + global $wp_filesystem;
34 + if (!function_exists('WP_Filesystem')) {
35 + require_once(ABSPATH . 'wp-admin/includes/file.php');
36 + }
37 + WP_Filesystem();
38 +
39 + return $wp_filesystem->get_contents($filePath);
30 40 }
31 41
32 42 /**
33 43 * Get custom upload dir name of this application
@@ -46,8 +56,117 @@
46 56 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
47 57 }
48 58
49 59 /**
60 + * Prepare local storage with Apache rules or verified host-managed protection.
61 + * Installing rules alone does not prove the HTTP server enforces them.
62 + * Pass false to defer any required HTTP verification to cron.
63 + */
64 + public function _installUploadProtectionRules($allowProbe = true)
65 + {
66 + // Protect the root even when called on a board-specific filesystem instance.
67 + $directory = (new static())->_getDir();
68 + if (!wp_mkdir_p($directory)) {
69 + return false;
70 + }
71 +
72 + $path = $directory . '/.htaccess';
73 + $rules = [
74 + '<IfModule mod_authz_core.c>',
75 + 'Require all denied',
76 + '</IfModule>',
77 + '<IfModule !mod_authz_core.c>',
78 + 'Order deny,allow',
79 + 'Deny from all',
80 + '</IfModule>',
81 + ];
82 +
83 + // Hosts may lock security configuration while leaving upload storage writable.
84 + if (is_file($path) && is_readable($path)) {
85 + $contents = str_replace("\r\n", "\n", file_get_contents($path));
86 + if (preg_match_all('/^# BEGIN Fluent Boards\n(.*?)^# END Fluent Boards$/ms', $contents, $blocks) === 1) {
87 + $existingRules = array_values(array_filter(array_map('trim', explode("\n", $blocks[1][0])), function ($line) {
88 + return $line !== '' && strpos($line, '#') !== 0;
89 + }));
90 + if ($existingRules === $rules) {
91 + return true;
92 + }
93 + }
94 + }
95 +
96 + if (!function_exists('insert_with_markers')) {
97 + require_once ABSPATH . 'wp-admin/includes/misc.php';
98 + }
99 +
100 + if (insert_with_markers($path, 'Fluent Boards', $rules)) {
101 + return true;
102 + }
103 +
104 + // Unmarked, locked host rules and server-level restrictions need no rewrite.
105 + return $this->_checkUploadProtection($allowProbe) === true;
106 + }
107 +
108 + /**
109 + * Probe direct access without login cookies using a harmless temporary file.
110 + * Return true for denied access, false for exposure, or null when unverified.
111 + * Pass false to read only the cache and schedule a missing check in cron.
112 + */
113 + public function _checkUploadProtection($allowProbe = true)
114 + {
115 + $directory = (new static())->_getDir();
116 + $uploads = wp_upload_dir();
117 + $folder = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
118 + $url = trailingslashit($uploads['baseurl']) . trim($folder, '/');
119 + $rulesPath = $directory . '/.htaccess';
120 + $rulesHash = is_file($rulesPath) && is_readable($rulesPath) ? hash_file('sha256', $rulesPath) : '';
121 + $cacheKey = Constant::UPLOAD_PROTECTION_CHECK_PREFIX . md5($directory . '|' . $url);
122 + $cached = get_transient($cacheKey);
123 + if (is_array($cached) && isset($cached['rules_hash']) && $cached['rules_hash'] === $rulesHash && array_key_exists('blocked', $cached)) {
124 + return $cached['blocked'];
125 + }
126 +
127 + if (!$allowProbe) {
128 + $hook = 'fluent_boards/check_upload_protection';
129 + if (!wp_next_scheduled($hook)) {
130 + wp_schedule_single_event(time(), $hook);
131 + }
132 + return null;
133 + }
134 +
135 + $blocked = null;
136 + $filename = 'fbs-access-check-' . wp_generate_uuid4() . '.txt';
137 + $path = trailingslashit($directory) . $filename;
138 + $contents = wp_generate_uuid4();
139 + try {
140 + if (is_dir($directory) && is_writable($directory)
141 + && file_put_contents($path, $contents, LOCK_EX) === strlen($contents)) {
142 + // The upload URL comes from trusted site configuration, never request input.
143 + $response = wp_remote_get($url . '/' . $filename, [
144 + 'timeout' => 5,
145 + 'redirection' => 0,
146 + 'limit_response_size' => 1024,
147 + 'headers' => ['Cache-Control' => 'no-cache'],
148 + ]);
149 + if (!is_wp_error($response)) {
150 + $status = wp_remote_retrieve_response_code($response);
151 + if (in_array($status, [403, 404], true)) {
152 + $blocked = true;
153 + } elseif ($status === 200 && wp_remote_retrieve_body($response) === $contents) {
154 + $blocked = false;
155 + }
156 + }
157 + }
158 + } finally {
159 + if (is_file($path)) {
160 + wp_delete_file($path);
161 + }
162 + }
163 +
164 + set_transient($cacheKey, ['rules_hash' => $rulesHash, 'blocked' => $blocked], 5 * MINUTE_IN_SECONDS);
165 + return $blocked;
166 + }
167 +
168 + /**
50 169 * Get absolute path of file using custom upload dir name of this application
51 170 * @return string [file path]
52 171 */
53 172 public function _getAbsolutePathOfFile($file)
@@ -55,9 +174,79 @@
55 174 return $this->_getDir() . DIRECTORY_SEPARATOR . $file;
56 175 }
57 176
58 177 /**
59 - * Upload files into custom upload dir of this application
178 + * Resolve a stored attachment path only when it belongs to Fluent Boards uploads.
179 + *
180 + * @param string $storedPath
181 + * @param int|null $boardId
182 + * @return string|null
183 + */
184 + public function _resolveLocalAttachmentPath($storedPath, $boardId = null)
185 + {
186 + if (!$storedPath) {
187 + return null;
188 + }
189 +
190 + $storedFilename = rawurldecode((string) $storedPath);
191 + $isBareFilename = $storedFilename !== ''
192 + && strpos($storedFilename, '/') === false
193 + && strpos($storedFilename, '\\') === false;
194 +
195 + if ($isBareFilename && $boardId === null) {
196 + return null;
197 + }
198 +
199 + $filePath = $isBareFilename ? null : realpath($storedPath);
200 + $allowedDirectory = $this->_getDir();
201 + $pluginRoot = realpath($allowedDirectory);
202 +
203 + if (!$pluginRoot) {
204 + return null;
205 + }
206 +
207 + $pluginRoot = rtrim($pluginRoot, DIRECTORY_SEPARATOR);
208 + $allowedRoot = $pluginRoot;
209 +
210 + if ($boardId !== null) {
211 + if (!is_int($boardId) && !is_string($boardId)) {
212 + return null;
213 + }
214 +
215 + $boardId = filter_var($boardId, FILTER_VALIDATE_INT, [
216 + 'options' => ['min_range' => 1],
217 + ]);
218 + if ($boardId === false) {
219 + return null;
220 + }
221 +
222 + $allowedDirectory .= DIRECTORY_SEPARATOR . 'board_' . $boardId;
223 + if (is_link($allowedDirectory)) {
224 + return null;
225 + }
226 +
227 + $allowedRoot = realpath($allowedDirectory);
228 + $expectedBoardRoot = $pluginRoot . DIRECTORY_SEPARATOR . 'board_' . $boardId;
229 + if (!$allowedRoot || $allowedRoot !== $expectedBoardRoot) {
230 + return null;
231 + }
232 + }
233 +
234 + if ($isBareFilename) {
235 + $filePath = realpath($allowedDirectory . DIRECTORY_SEPARATOR . $storedFilename);
236 + }
237 +
238 + if (!$filePath || !$allowedRoot || !is_file($filePath)) {
239 + return null;
240 + }
241 +
242 + $allowedRoot = rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
243 +
244 + return strpos($filePath, $allowedRoot) === 0 ? $filePath : null;
245 + }
246 +
247 + /**
248 + * Upload request files into the application's custom upload directory.
60 249 * @return array
61 250 */
62 251 public function _uploadFromRequest()
63 252 {
@@ -64,14 +253,18 @@
64 253 return $this->_put(FluentBoards('request')->files());
65 254 }
66 255
67 256 /**
68 - * Upload files into custom upload dir of this application
257 + * Prepare upload-directory protection before accepting local files.
69 258 * @param array $files
70 259 * @return array
71 260 */
72 261 public function _put($files)
73 262 {
263 + if (!$this->_installUploadProtectionRules()) {
264 + throw new Exception(__('Could not prepare upload-directory protection. Please ask your hosting provider to block direct file access and check directory permissions.', 'fluent-boards'));
265 + }
266 +
74 267 if (!function_exists('wp_handle_upload')) {
75 268 require_once(ABSPATH . 'wp-admin/includes/file.php');
76 269 }
77 270
@@ -76,14 +269,32 @@
76 269 }
77 270
78 271 $this->overrideUploadDir();
79 272
80 - $uploadOverrides = ['test_form' => false];
273 + $uploadOverrides = [
274 + 'test_form' => false,
275 + // Accept the same allow-list FluentBoards validates against, so wp_handle_upload
276 + // doesn't reject broader types (e.g. .json, .md) by extension.
277 + 'mimes' => \FluentBoards\App\Services\UploadService::getAllowedMimeMap(),
278 + ];
279 + $uploadedFiles = []; // Initialize the array
81 280
281 + if(is_object($files)) {
282 + $files = [$files];
283 + }
284 +
82 285 foreach ((array)$files as $file) {
286 +
83 287 $filesArray = $file->toArray();
288 +
289 + // tmp_name is the path to the uploaded file which is required for wp_handle_upload, new framework update
290 + // changed the way to get the tmp_name Reference to line # 448 in File.php
291 + $filesArray['tmp_name'] = $file->getRealPath();
292 +
84 293 $extraData = Arr::only($filesArray, ['name', 'size']);
85 294 $uploadsData = \wp_handle_upload($filesArray, $uploadOverrides);
295 + // Add the full path to the file
296 + $uploadsData['full_path'] = $this->_getAbsolutePathOfFile($uploadsData['file']);
86 297 $uploadedFiles[] = array_merge($extraData, $uploadsData);
87 298 }
88 299
89 300 return $uploadedFiles;
@@ -100,9 +311,12 @@
100 311
101 312 foreach ($files as $file) {
102 313 $arr = explode('/', $file);
103 314 $fileName = end($arr);
104 - @unlink($this->getDir() . '/' . $fileName);
315 + $filePath = $this->getDir() . '/' . $fileName;
316 + if (file_exists($filePath)) {
317 + wp_delete_file($filePath);
318 + }
105 319 }
106 320 }
107 321
108 322 /**
@@ -134,8 +348,9 @@
134 348
135 349 if ($this->subDir) {
136 350 $fbsUploadDir .= DIRECTORY_SEPARATOR . $this->subDir;
137 351 if (!is_dir($param['basedir'] . $fbsUploadDir)) {
352 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Creating custom upload subdirectory during upload process, WP_Filesystem not initialized
138 353 @mkdir($param['basedir'] . $fbsUploadDir, 0755);
139 354 }
140 355 }
141 356
@@ -152,14 +367,45 @@
152 367 * @return array $file
153 368 */
154 369 public function _renameFileName($file)
155 370 {
156 - $currentTimeStamp = (new \DateTimeImmutable())->getTimestamp();
157 - $prefix = $currentTimeStamp . '-';
371 + $prefix = wp_generate_uuid4() . '-';
158 372 $prefix = apply_filters('fluent_boards/uploaded_file_name_prefix', $prefix);
159 373 $file['name'] = $prefix . $file['name'];
160 374
161 375 return $file;
376 + }
377 +
378 + public function _deleteDir($dir)
379 + {
380 + $dir = $this->getDir() . DIRECTORY_SEPARATOR . $dir;
381 + if (is_dir($dir)) {
382 + $this->deleteContents($dir);
383 + }
384 + }
385 +
386 + /**
387 + * Delete a directory and its content
388 + * Lead Note: this method should be called via scheduler or queue
389 + * @param string $dir
390 + * @return bool
391 + */
392 + private function deleteContents($dir)
393 + {
394 + $files = array_diff(scandir($dir), ['.', '..']);
395 + foreach ($files as $file) {
396 + if(is_dir("$dir/$file")) {
397 + // Recursively delete subdirectory
398 + $this->deleteContents("$dir/$file");
399 + } else {
400 + // Delete file
401 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Recursive directory cleanup, direct file operations required
402 + unlink("$dir/$file");
403 + }
404 + }
405 + // Delete the directory itself. If the directory is not empty, it will not be deleted.
406 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Recursive directory removal, WP_Filesystem not suitable for this operation
407 + return rmdir($dir);
162 408 }
163 409
164 410 public static function __callStatic($method, $params)
165 411 {