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.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 1.40 All 43 releases
← All changes | app/Services/Libs/FileSystem.php +187 -2 2.0.12 → 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 = '';
@@ -54,8 +56,117 @@
54 56 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
55 57 }
56 58
57 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 + /**
58 169 * Get absolute path of file using custom upload dir name of this application
59 170 * @return string [file path]
60 171 */
61 172 public function _getAbsolutePathOfFile($file)
@@ -63,9 +174,79 @@
63 174 return $this->_getDir() . DIRECTORY_SEPARATOR . $file;
64 175 }
65 176
66 177 /**
67 - * 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.
68 249 * @return array
69 250 */
70 251 public function _uploadFromRequest()
71 252 {
@@ -72,14 +253,18 @@
72 253 return $this->_put(FluentBoards('request')->files());
73 254 }
74 255
75 256 /**
76 - * Upload files into custom upload dir of this application
257 + * Prepare upload-directory protection before accepting local files.
77 258 * @param array $files
78 259 * @return array
79 260 */
80 261 public function _put($files)
81 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 +
82 267 if (!function_exists('wp_handle_upload')) {
83 268 require_once(ABSPATH . 'wp-admin/includes/file.php');
84 269 }
85 270