PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 2.10.1 All 138 releases
← All changes | includes/Core/Util/FileHandler.php +808 -81 2.03.3.1 View file →
@@ -1,32 +1,29 @@
1 1 <?php
2 2
3 3 namespace BitCode\BitForm\Core\Util;
4 4
5 +use BitCode\BitForm\Admin\Form\Helpers;
5 6 use BitCode\BitForm\Core\Form\FormManager;
7 +use BitCode\BitForm\enshrined\svgSanitize\Sanitizer;
6 8
7 -final class FileHandler {
8 - public function rmrf($dir) {
9 - if (is_dir($dir)) {
10 - $objects = scandir($dir);
11 - foreach ($objects as $object) {
12 - if ('.' !== $object && '..' !== $object) {
13 - if (is_dir($dir . DIRECTORY_SEPARATOR . $object) && !is_link($dir . DIRECTORY_SEPARATOR . $object)) {
14 - $this->rmrf($dir . DIRECTORY_SEPARATOR . $object);
15 - } else {
16 - unlink($dir . DIRECTORY_SEPARATOR . $object);
17 - }
18 - }
19 - }
20 - rmdir($dir);
21 - } else {
22 - unlink($dir);
9 +if (!defined('ABSPATH')) {
10 + exit;
11 +}
12 +final class FileHandler
13 +{
14 + public function rmrf($dir)
15 + {
16 + $fs = self::initWpFilesystem();
17 + if ($fs && $fs->exists($dir)) {
18 + $fs->delete($dir, true);
23 19 }
24 20 }
25 21
26 - public function cpyr($source, $destination) {
22 + public function cpyr($source, $destination)
23 + {
27 24 if (is_dir($source)) {
28 - mkdir($destination);
25 + wp_mkdir_p($destination);
29 26 // chmod($destination, 0744);
30 27 $objects = scandir($source);
31 28 foreach ($objects as $object) {
32 29 if ('.' !== $object && '..' !== $object) {
@@ -33,9 +30,9 @@
33 30 if (is_dir($source . DIRECTORY_SEPARATOR . $object) && !is_link($source . DIRECTORY_SEPARATOR . $object)) {
34 31 cpyr($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
35 32 } elseif (is_file($source . DIRECTORY_SEPARATOR . $object)) {
36 33 copy($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
37 - // chmod($destination. DIRECTORY_SEPARATOR .$object, 0644);
34 + // chmod($destination. DIRECTORY_SEPARATOR .$object, 0644);
38 35 } else {
39 36 symlink($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
40 37 }
41 38 }
@@ -44,18 +41,30 @@
44 41 copy($source, $destination);
45 42 }
46 43 }
47 44
48 - public function moveUploadedFiles($file_details, $form_id, $entry_id) {
45 + public function moveUploadedFiles($file_details, $form_id, $entry_id)
46 + {
47 + require_once ABSPATH . 'wp-admin/includes/file.php';
48 +
49 49 $file_upoalded = [];
50 - $_upload_dir = BITFORMS_UPLOAD_DIR . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR . $entry_id;
51 - wp_mkdir_p($_upload_dir);
50 + $_upload_dir = self::getEntriesFileUploadDir($form_id, $entry_id);
51 + $_upload_url = self::getEntriesFileUploadURL($form_id, $entry_id);
52 + $this::createIndexFile($_upload_dir);
53 +
54 + $upload_dir_filter = function ($uploads) use ($_upload_dir, $_upload_url) {
55 + $uploads['path'] = $_upload_dir;
56 + $uploads['url'] = $_upload_url;
57 + $uploads['subdir'] = '';
58 + $uploads['basedir'] = dirname($_upload_dir);
59 + $uploads['baseurl'] = dirname($_upload_url);
60 + return $uploads;
61 + };
62 +
52 63 if (is_array($file_details['name'])) {
53 64 foreach ($file_details['name'] as $key => $value) {
54 - //check accepted filetype in_array($file_details['name'][$key], $supported_files) else \
55 65 if (!empty($value)) {
56 66 $fileNameCount = 1;
57 - // $file_upoalded[$key] = time()."_$value";
58 67 $file_upoalded[$key] = sanitize_file_name($value);
59 68 while (file_exists($_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[$key])) {
60 69 $fileNameWithSeparator = BITFORMS_BF_SEPARATOR . $fileNameCount;
61 70 $file_upoalded[$key] = sanitize_file_name(preg_replace('/(.[a-z A-Z 0-9]+)$/', "{$fileNameWithSeparator}$1", $value));
@@ -63,11 +72,22 @@
63 72 if (11 === $fileNameCount) {
64 73 break;
65 74 }
66 75 }
67 - $move_status = \move_uploaded_file($file_details['tmp_name'][$key], $_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[$key]);
68 - if (!$move_status) {
76 + $file = [
77 + 'name' => $file_upoalded[$key],
78 + 'type' => $file_details['type'][$key] ?? '',
79 + 'tmp_name' => $file_details['tmp_name'][$key],
80 + 'error' => $file_details['error'][$key] ?? 0,
81 + 'size' => $file_details['size'][$key] ?? 0,
82 + ];
83 + add_filter('upload_dir', $upload_dir_filter);
84 + $upload_result = wp_handle_upload($file, ['test_form' => false]);
85 + remove_filter('upload_dir', $upload_dir_filter);
86 + if (isset($upload_result['error'])) {
69 87 unset($file_upoalded[$key]);
88 + } else {
89 + $file_upoalded[$key] = basename($upload_result['file']);
70 90 }
71 91 }
72 92 }
73 93 } else {
@@ -81,11 +101,22 @@
81 101 if (11 === $fileNameCount) {
82 102 break;
83 103 }
84 104 }
85 - $move_status = \move_uploaded_file($file_details['tmp_name'], $_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[0]);
86 - if (!$move_status) {
105 + $file = [
106 + 'name' => $file_upoalded[0],
107 + 'type' => $file_details['type'] ?? '',
108 + 'tmp_name' => $file_details['tmp_name'],
109 + 'error' => $file_details['error'] ?? 0,
110 + 'size' => $file_details['size'] ?? 0,
111 + ];
112 + add_filter('upload_dir', $upload_dir_filter);
113 + $upload_result = wp_handle_upload($file, ['test_form' => false]);
114 + remove_filter('upload_dir', $upload_dir_filter);
115 + if (isset($upload_result['error'])) {
87 116 unset($file_upoalded[0]);
117 + } else {
118 + $file_upoalded[0] = basename($upload_result['file']);
88 119 }
89 120 }
90 121 }
91 122 return $file_upoalded;
@@ -90,16 +121,64 @@
90 121 }
91 122 return $file_upoalded;
92 123 }
93 124
94 - public function deleteFiles($form_id, $entry_id, $files) {
95 - $_upload_dir = BITFORMS_UPLOAD_DIR . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR . $entry_id;
96 - foreach ($files as $name) {
97 - unlink($_upload_dir . DIRECTORY_SEPARATOR . $name);
125 + public static function isSafeFileName($name)
126 + {
127 + if (!is_string($name)) {
128 + return false;
98 129 }
130 +
131 + $trimmed = trim($name);
132 + if ('' === $trimmed) {
133 + return false;
134 + }
135 +
136 + $baseName = basename($trimmed);
137 + if ('' === $baseName || $baseName !== $trimmed || 'index.php' === $baseName) {
138 + return false;
139 + }
140 +
141 + return sanitize_file_name($trimmed) === $trimmed;
99 142 }
100 143
101 - public static function getFileUploadError($code) {
144 + public function deleteFiles($form_id, $entry_id, $files)
145 + {
146 + $_upload_dir = self::getEntriesFileUploadDir($form_id, $entry_id);
147 + $resolvedBitformsUploadDir = realpath(BITFORMS_UPLOAD_DIR);
148 + $resolvedUploadDir = realpath($_upload_dir);
149 + if (false === $resolvedBitformsUploadDir || false === $resolvedUploadDir) {
150 + return;
151 + }
152 +
153 + $bitformsUploadDirPrefix = trailingslashit(wp_normalize_path($resolvedBitformsUploadDir));
154 + $uploadDirPrefix = trailingslashit(wp_normalize_path($resolvedUploadDir));
155 + if (0 !== strpos($uploadDirPrefix, $bitformsUploadDirPrefix)) {
156 + return;
157 + }
158 +
159 + foreach ((array) $files as $name) {
160 + if (!self::isSafeFileName($name)) {
161 + continue;
162 + }
163 +
164 + $candidatePath = $resolvedUploadDir . DIRECTORY_SEPARATOR . $name;
165 + $resolvedPath = realpath($candidatePath);
166 + if (false === $resolvedPath || !is_file($resolvedPath)) {
167 + continue;
168 + }
169 +
170 + $normalizedPath = wp_normalize_path($resolvedPath);
171 + if (0 !== strpos($normalizedPath, $uploadDirPrefix)) {
172 + continue;
173 + }
174 +
175 + wp_delete_file($resolvedPath);
176 + }
177 + }
178 +
179 + public static function getFileUploadError($code)
180 + {
102 181 $errors = [
103 182 0 => __('Unknown upload error', 'bit-form'),
104 183 1 => __('The uploaded file exceeds the upload_max_filesize directive in php.ini.', 'bit-form'),
105 184 2 => __('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', 'bit-form'),
@@ -111,35 +190,67 @@
111 190 ];
112 191 return $errors[$code];
113 192 }
114 193
115 - public static function fileCopy($tmpdir, $destinationDir, $file) {
116 - $tmpFile = $tmpdir . DIRECTORY_SEPARATOR . $file;
117 - $newFile = $destinationDir . DIRECTORY_SEPARATOR . $file;
118 - if (file_exists($tmpFile)) {
119 - copy($tmpFile, $newFile);
194 + public static function fileCopy($tmpdir, $destinationDir, $file)
195 + {
196 + $tmpBase = realpath($tmpdir);
197 + $destBase = realpath($destinationDir);
198 + if (false === $tmpBase || false === $destBase) {
199 + Log::debug_log([
200 + 'message' => 'FileHandler::fileCopy base path invalid',
201 + 'tmpdir' => $tmpdir,
202 + 'destinationDir' => $destinationDir,
203 + ]);
204 + return;
120 205 }
206 +
207 + $safeFile = is_string($file) ? trim($file) : '';
208 + if ('' === $safeFile) {
209 + return;
210 + }
211 +
212 + $candidate = $tmpBase . DIRECTORY_SEPARATOR . $safeFile;
213 + $resolved = realpath($candidate);
214 + if (false === $resolved || 0 !== strpos($resolved, $tmpBase . DIRECTORY_SEPARATOR)) {
215 + Log::debug_log([
216 + 'message' => 'FileHandler::fileCopy blocked path traversal',
217 + 'file' => $file,
218 + 'resolved' => $resolved,
219 + 'tmpBase' => $tmpBase,
220 + ]);
221 + return;
222 + }
223 + if (!is_readable($resolved)) {
224 + return;
225 + }
226 +
227 + $newFile = $destBase . DIRECTORY_SEPARATOR . basename($resolved);
228 + copy($resolved, $newFile);
121 229 }
122 230
123 - public static function tempDirToUploadDir($submitted_data, $fields, $formId, $entryID) {
231 + public static function tempDirToUploadDir($submitted_data, $fields, $formId, $entryID)
232 + {
124 233 $upload_dir = wp_upload_dir();
125 234 $tempDir = $upload_dir['basedir'] . '/bitforms/temp';
126 - $destinationDir = BITFORMS_UPLOAD_DIR . DIRECTORY_SEPARATOR . $formId . DIRECTORY_SEPARATOR . $entryID . DIRECTORY_SEPARATOR;
127 - if (!is_dir($destinationDir)) {
128 - mkdir($destinationDir);
129 - }
235 + $destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR;
236 + self::createIndexFile($destinationDir);
130 237
238 + $consumedFiles = [];
239 +
131 240 foreach ($submitted_data as $key => $data) {
132 241 if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) {
133 - $files = $data;
134 242 $fldData = $submitted_data[$key];
135 - $files = explode(',', $fldData);
136 - if (is_array($files) && count($files) > 0) {
137 - foreach ($files as $file) {
138 - self::fileCopy($tempDir, $destinationDir, trim($file));
243 + // A repeater row (or a pre-split value) hands this field over as an array; explode()
244 + // on an array is a TypeError on PHP 8, which would fatal mid-submission.
245 + $files = \is_array($fldData) ? $fldData : explode(',', \is_scalar($fldData) ? (string) $fldData : '');
246 + foreach ($files as $file) {
247 + $safeFile = \is_scalar($file) ? trim((string) $file) : '';
248 + if ('' === $safeFile) {
249 + continue;
139 250 }
140 - } else {
141 - self::fileCopy($tempDir, $destinationDir, trim($files));
251 + self::fileCopy($tempDir, $destinationDir, $safeFile);
252 + $consumedFiles[] = $safeFile;
142 253 }
143 254 if (!empty($files)) {
144 255 $submitted_data[$key] = $files;
145 256 }
@@ -144,16 +255,72 @@
144 255 $submitted_data[$key] = $files;
145 256 }
146 257 }
147 258 }
148 - array_map('unlink', array_filter(
149 - (array) array_merge(glob("$tempDir/*"))
150 - ));
151 259
260 + self::cleanupTempUploads($tempDir, $consumedFiles);
261 +
152 262 return $submitted_data;
153 263 }
154 264
155 - private function getByteSizeByUnit($sizeString) {
265 + /**
266 + * Clear the shared temp upload staging area after a submission has taken what it needs.
267 + *
268 + * @param string $tempDir shared staging directory
269 + * @param array $consumedFiles file names copied into the entry directory by this submission
270 + *
271 + * @return void
272 + */
273 + private static function cleanupTempUploads($tempDir, array $consumedFiles)
274 + {
275 + $tempBase = realpath($tempDir);
276 + if (false === $tempBase) {
277 + return;
278 + }
279 + $boundary = $tempBase . DIRECTORY_SEPARATOR;
280 +
281 + // Never remove the directory-hardening files the uploads dir relies on.
282 + $protected = ['index.php', 'index.html', '.htaccess'];
283 +
284 + foreach ($consumedFiles as $file) {
285 + $resolved = realpath($boundary . $file);
286 + if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
287 + continue;
288 + }
289 + if (\in_array(basename($resolved), $protected, true)) {
290 + continue;
291 + }
292 + wp_delete_file($resolved);
293 + }
294 +
295 + // Sweep abandoned uploads. Anything still here after the retention window belongs to a form
296 + // that was never submitted. Filterable so a site with very long multi-step forms can extend
297 + // it; 0 or less disables the sweep entirely.
298 + $retention = apply_filters('bitform_temp_upload_retention', DAY_IN_SECONDS);
299 + $retention = is_numeric($retention) ? (int) $retention : DAY_IN_SECONDS;
300 + if ($retention <= 0) {
301 + return;
302 + }
303 +
304 + $cutoff = time() - $retention;
305 + $tmpFiles = glob($boundary . '*');
306 + foreach ((array) $tmpFiles as $tmpFile) {
307 + $resolved = realpath($tmpFile);
308 + if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
309 + continue;
310 + }
311 + if (\in_array(basename($resolved), $protected, true)) {
312 + continue;
313 + }
314 + $modified = @filemtime($resolved);
315 + if (false !== $modified && $modified < $cutoff) {
316 + wp_delete_file($resolved);
317 + }
318 + }
319 + }
320 +
321 + private function getByteSizeByUnit($sizeString)
322 + {
156 323 // split 2MB into 2 and MB
157 324 $size = preg_replace('/[^0-9\.]/', '', $sizeString);
158 325 $unit = preg_replace('/[^a-zA-Z]/', '', $sizeString);
159 326 $unit = strtolower($unit);
@@ -167,14 +334,15 @@
167 334 return $size;
168 335 }
169 336 }
170 337
171 - public function validation($field_key, $file_details, $form_id) {
338 + public function validation($field_key, $file_details, $form_id)
339 + {
172 340 if (!function_exists('wp_check_filetype_and_ext')) {
173 341 require_once ABSPATH . 'wp-admin/includes/file.php';
174 342 }
175 343
176 - $formManager = new FormManager($form_id);
344 + $formManager = FormManager::getInstance($form_id);
177 345 $form_contents = $formManager->getFormContent();
178 346 $field_content_details = $form_contents->fields;
179 347 $fieldDetail = $field_content_details->{$field_key};
180 348 $fieldType = $fieldDetail->typ;
@@ -179,8 +347,9 @@
179 347 $fieldDetail = $field_content_details->{$field_key};
180 348 $fieldType = $fieldDetail->typ;
181 349 $maxSizeDetails = [];
182 350 $allowFileTypes = [];
351 + $maxSize = null;
183 352 if ('file-up' === $fieldType) {
184 353 $allowFileTypes = !empty($fieldDetail->config->allowedFileType) ? $fieldDetail->config->allowedFileType : [];
185 354 if (!empty($fieldDetail->config->allowMaxSize)) {
186 355 if (!empty($fieldDetail->config->maxSize)) {
@@ -206,20 +375,44 @@
206 375 }
207 376 if (!empty($maxSizeDetails['maxSize'])) {
208 377 $maxSize = $this->getByteSizeByUnit($maxSizeDetails['maxSize']);
209 378 }
379 + $maxTotalFileSize = null;
210 380 if (!empty($maxSizeDetails['maxTotalFileSize'])) {
211 381 $maxTotalFileSize = $this->getByteSizeByUnit($maxSizeDetails['maxTotalFileSize']);
212 382 }
213 383
384 + if ($formManager->isRepeatedField($field_key)) {
385 + foreach ($file_details['name'] as $rowIndex => $file) {
386 + if (!empty($file)) {
387 + $fileDetails = [
388 + 'name' => $file,
389 + 'type' => $file_details['type'][$rowIndex],
390 + 'tmp_name' => $file_details['tmp_name'][$rowIndex],
391 + 'error' => $file_details['error'][$rowIndex],
392 + 'size' => $file_details['size'][$rowIndex],
393 + ];
394 + $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize);
395 + if (!empty($validateState) && !empty($validateState['message'])) {
396 + return $validateState;
397 + }
398 + }
399 + }
400 + } else {
401 + return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize);
402 + }
403 + return [];
404 + }
405 +
406 + private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize)
407 + {
214 408 $errorMessage = [
215 - 'message' => '',
216 - 'error_type'=> '',
409 + 'message' => '',
410 + 'error_type' => '',
217 411 ];
218 -
219 412 if (is_array($file_details['name'])) {
220 413 $totalSize = 0;
221 - foreach ($file_details['name'] as $key => $file) {
414 + foreach ($file_details['name'] as $key => $file) {
222 415 if (!empty($file)) {
223 416 $fileInfo = [
224 417 'name' => $file,
225 418 'type' => $file_details['type'][$key],
@@ -233,9 +426,9 @@
233 426 return $validateState;
234 427 }
235 428 }
236 429 }
237 - if (!is_null($maxTotalFileSize) && $totalSize > $maxTotalFileSize) {
430 + if (isset($maxTotalFileSize) && !is_null($maxTotalFileSize) && $totalSize > $maxTotalFileSize) {
238 431 $errorMessage['message'] = __('Total File size is too large', 'bit-form');
239 432 $errorMessage['error_type'] = 'file_size_error';
240 433 return $errorMessage;
241 434 }
@@ -248,31 +441,565 @@
248 441
249 442 return $errorMessage;
250 443 }
251 444
252 - private function validateSingleFile($fieldType, $file, $allowTypes, $maxSize) {
253 - $fileName = sanitize_file_name($file['name']);
254 - if (!empty($fileName)) {
255 - $fileSize = $file['size'];
256 - if (!empty($maxSize) && $fileSize > $maxSize) {
257 - return [
258 - 'message' => __('File size is too large', 'bit-form'),
259 - 'error_type'=> 'file_size_error',
260 - ];
445 + private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null)
446 + {
447 + // 0) Basic sanity & transport integrity
448 + if (!is_array($file) || empty($file['tmp_name'])) {
449 + // return ['message' => __('No file uploaded.', 'bit-form'), 'error_type' => 'file_missing'];
450 + return null;
451 + }
452 + if (!isset($file['error']) || UPLOAD_ERR_OK !== (int)$file['error']) {
453 + return ['message' => __('Upload failed', 'bit-form'), 'error_type' => 'file_upload_error'];
454 + }
455 + if (!is_uploaded_file($file['tmp_name'])) {
456 + return ['message' => __('Untrusted upload source', 'bit-form'), 'error_type' => 'file_upload_error'];
457 + }
458 + if (!is_file($file['tmp_name']) || !is_readable($file['tmp_name'])) {
459 + return ['message' => __('Temporary file not accessible', 'bit-form'), 'error_type' => 'file_upload_error'];
460 + }
461 +
462 + $fileName = sanitize_file_name((string)($file['name'] ?? ''));
463 + if ('' === $fileName) {
464 + return ['message' => __('Empty filename', 'bit-form'), 'error_type' => 'file_type_error'];
465 + }
466 +
467 + // 1) Enforce max size (header + actual)
468 + $onDiskSize = @filesize($file['tmp_name']);
469 + if (false === $onDiskSize) {
470 + return ['message' => __('Cannot read file size', 'bit-form'), 'error_type' => 'file_upload_error'];
471 + }
472 + if (!empty($maxSize) && $onDiskSize > $maxSize) {
473 + return ['message' => __('File size is too large', 'bit-form'), 'error_type' => 'file_size_error'];
474 + }
475 +
476 + // 2) Determine ext + MIME using WP + finfo
477 + $wpCheck = wp_check_filetype_and_ext($file['tmp_name'], $fileName); // ['ext'=>'jpg','type'=>'image/jpeg']
478 + $fileExtension = strtolower((string)(empty($wpCheck['ext']) ? pathinfo($fileName, PATHINFO_EXTENSION) : $wpCheck['ext']));
479 + $wpExt = strtolower((string)(empty($wpCheck['ext']) ? $fileExtension : $wpCheck['ext']));
480 + $wpType = strtolower((string)($wpCheck['type'] ?? ''));
481 + $fi = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false;
482 + $detectedMime = $fi ? @finfo_file($fi, $file['tmp_name']) : false;
483 + if ($fi) {
484 + @finfo_close($fi);
485 + }
486 + if (!$detectedMime && function_exists('mime_content_type')) {
487 + $detectedMime = @mime_content_type($file['tmp_name']);
488 + }
489 + if (!$detectedMime) {
490 + $detectedMime = '' !== $wpType ? $wpType : 'application/octet-stream';
491 + }
492 + $detectedMime = strtolower(trim($detectedMime));
493 +
494 + // Hard-block risky types regardless
495 + // 3) Block obvious executable types regardless of allow list
496 + $disallowedMimes = apply_filters('bitform_filter_upload_disallowed_mimes', [
497 + 'application/x-php',
498 + 'text/x-php',
499 + 'application/x-msdownload',
500 + 'application/x-msdos-program',
501 + 'application/x-sh',
502 + 'application/x-csh',
503 + 'text/x-shellscript',
504 + 'application/java-archive'
505 + ]);
506 + $denyExt = apply_filters('bitform_filter_upload_denied_extensions', ['php', 'phtml', 'phar', 'htaccess', 'html', 'js', 'exe', 'sh', 'bat', 'cmd']);
507 + if (in_array($detectedMime, $disallowedMimes, true) || in_array($fileExtension, $denyExt, true)) {
508 + return ['message' => __('This file type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
509 + }
510 +
511 + // 4) Normalize allowlist: support both extensions (.jpg or jpg) and MIME types
512 + // --- ALLOWLIST NORMALIZATION (extensions + MIME) ---
513 + $normalizedAllow = array_values(array_unique(array_map(static function ($t) {
514 + return strtolower(trim((string)$t));
515 + }, (array)$allowTypes)));
516 +
517 + $allowExts = [];
518 + $allowMimes = [];
519 + foreach ($normalizedAllow as $t) {
520 + if ('' === $t) {
521 + continue;
261 522 }
523 + if (false !== strpos($t, '/')) {
524 + // looks like a MIME
525 + $allowMimes[] = $t;
526 + } else {
527 + // extension: may include leading dot; normalize without dot
528 + $allowExts[] = ltrim($t, '.');
529 + }
530 + }
262 531
263 - $fileExtension = pathinfo($fileName, PATHINFO_EXTENSION);
264 - $fileExtAllowedByWp = wp_check_filetype_and_ext($file['tmp_name'], $fileName);
265 - $isAllowedFileType = in_array('.' . $fileExtension, $allowTypes);
266 - if ('advanced-file-up' === $fieldType && !empty($allowTypes)) {
267 - $fileMimeType = mime_content_type($file['tmp_name']);
268 - $isAllowedFileType = in_array($fileMimeType, $allowTypes);
532 + $allowExts = array_values(array_unique($allowExts));
533 + $allowMimes = array_values(array_unique($allowMimes));
534 +
535 + $hasAllowlist = (!empty($allowExts) || !empty($allowMimes));
536 + $extMatch = in_array($fileExtension, $allowExts, true);
537 + $mimeMatch = in_array($detectedMime, $allowMimes, true);
538 +
539 + // 5) Require BOTH a legit WP mapping AND a match to the allowlist
540 + $wpOk = ('' !== $wpExt && '' !== $wpType);
541 +
542 + if ($hasAllowlist) {
543 + if (!($extMatch || $mimeMatch)) {
544 + return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
269 545 }
270 - if ((!empty($allowTypes) && !$isAllowedFileType) || (empty($allowTypes) && empty($fileExtAllowedByWp['ext']))) {
271 - return [
272 - 'message' => __(($fileExtension ? ".{$fileExtension}" : 'empty') . ' file extension is not allowed', 'bit-form'),
273 - 'error_type'=> 'file_type_error',
274 - ];
546 + } else {
547 + if (!$wpOk) {
548 + return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
275 549 }
550 + }
551 +
552 + // 5) Special SVG handling (by MIME, not just extension)
553 + if ('svg' === $fileExtension || 'image/svg+xml' === $detectedMime || 'image/svg+xml' === $wpType) {
554 + if ('image/svg+xml' !== $detectedMime) {
555 + return ['message' => __('Invalid SVG', 'bit-form'), 'error_type' => 'file_type_error'];
556 + }
557 +
558 + $dirty = file_get_contents($file['tmp_name']);
559 + $svg_sanitizer = new Sanitizer();
560 + $clean = $svg_sanitizer->sanitize($dirty);
561 + if (false === $clean) {
562 + return ['message' => __('SVG file is not valid', 'bit-form'), 'error_type' => 'file_type_error'];
563 + }
564 + file_put_contents($file['tmp_name'], $clean, LOCK_EX);
565 + // Re-check size post-sanitize
566 + if (!empty($maxSize) && filesize($file['tmp_name']) > $maxSize) {
567 + return ['message' => __('File size is too large after sanitation', 'bit-form'), 'error_type' => 'file_size_error'];
568 + }
569 + }
570 +
571 + if ('pdf' === $fileExtension || 'application/pdf' === $detectedMime || 'application/pdf' === $wpType) {
572 + $blockedList = [
573 + '/\/JS\b/',
574 + '/\/JavaScript\b/',
575 + '/eval\(/i',
576 + '/app\.alert/i',
577 + '/console\.log\b/i',
578 + '/document\.write\b/i',
579 +
580 + '/\/GoToR\b/i',
581 + '/\/Launch\b/i',
582 +
583 + '/\/EmbeddedFile\b/i',
584 + '/\/EmbeddedFiles\b/i',
585 + '/\/Filespec\b/i',
586 + '/\/FileAttachment\b/i',
587 +
588 + '/\/SubmitForm\b/i',
589 + '/\/ResetForm\b/i',
590 + '/\/ImportData\b/i',
591 +
592 + '/\/RichMedia\b/i',
593 + ];
594 +
595 + $pdfContent = file_get_contents($file['tmp_name']);
596 + foreach ($blockedList as $blockedKeyWrd) {
597 + if (preg_match($blockedKeyWrd, $pdfContent)) {
598 + return ['message' => __('Invalid file', 'bit-form'), 'error_type' => 'file_type_error'];
599 + }
600 + }
601 + }
602 +
603 + // --- Allow developer to make a final decision override (optional) ---
604 + $final = apply_filters('bitform_filter_upload_allow_file', true, [
605 + 'file_name' => $fileName,
606 + 'extension' => $fileExtension,
607 + 'detected_mime' => $detectedMime,
608 + 'wp_ext' => $wpExt,
609 + 'wp_type' => $wpType,
610 + 'allow_exts' => $allowExts,
611 + 'allow_mimes' => $allowMimes,
612 + 'has_allowlist' => $hasAllowlist,
613 + ]);
614 + if (true !== $final) {
615 + // If a dev returns a WP_Error, you could extract message/type; here we just block.
616 + return ['message' => __('File not allowed by policy', 'bit-form'), 'error_type' => 'file_policy_block'];
617 + }
618 + return null; // success
619 + }
620 +
621 + public static function deleteIsFileExists($path)
622 + {
623 + if (file_exists($path)) {
624 + wp_delete_file($path);
625 + }
626 + }
627 +
628 + public static function getEntriesFileUploadDir($form_id, $entry_id)
629 + {
630 + $uploadDir = rtrim(BITFORMS_UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR;
631 + $encrypted_directoryId = Helpers::getEncryptedEntryId($entry_id);
632 + $encryptedDirectory = $uploadDir . $encrypted_directoryId;
633 + if (is_dir($encryptedDirectory)) {
634 + return $encryptedDirectory;
635 + }
636 + $oldEntriesFileUploadDir = Helpers::getOldEntriesFileUploadDir($uploadDir, $entry_id);
637 + if (!empty($oldEntriesFileUploadDir) && is_dir($oldEntriesFileUploadDir)) {
638 + return $oldEntriesFileUploadDir;
639 + }
640 + return $encryptedDirectory;
641 + }
642 +
643 + private static function replaceDocumentRoot($path)
644 + {
645 + $uploadDir = wp_upload_dir();
646 + $basedir = wp_normalize_path(rtrim($uploadDir['basedir'], '/\\'));
647 + $baseurl = rtrim($uploadDir['baseurl'], '/');
648 + $normalPath = wp_normalize_path($path);
649 +
650 + if (0 === strpos($normalPath, $basedir . '/') || $normalPath === $basedir) {
651 + return $baseurl . substr($normalPath, strlen($basedir));
652 + }
653 +
654 + // Fallback: path outside uploads dir — strip ABSPATH and prepend home URL.
655 + return home_url(ltrim(str_replace(wp_normalize_path(ABSPATH), '', $normalPath), '/'));
656 + }
657 +
658 + public static function getEntriesFileUploadURL($form_id, $entry_id)
659 + {
660 + $documentRoot = self::getEntriesFileUploadDir($form_id, $entry_id);
661 + $url = self::replaceDocumentRoot($documentRoot);
662 + return $url;
663 + }
664 +
665 + public static function createIndexFile($directory)
666 + {
667 + if (wp_mkdir_p($directory)) {
668 + $indexFilePath = rtrim($directory, '/') . '/index.php';
669 + if (!file_exists($indexFilePath)) {
670 + try {
671 + if (false === self::writeFile($indexFilePath, "<?php\n// No direct access allowed.")) {
672 + throw new \Exception("Failed to create index.php in $directory");
673 + }
674 + } catch (\Exception $e) {
675 + Log::debug_log('File creation Failed:' . $e->getMessage()); // Log the error for debugging
676 + }
677 + }
678 + }
679 + return false;
680 + }
681 +
682 + /**
683 + * Initialise and return the WP_Filesystem abstraction layer.
684 + *
685 + * @return WP_Filesystem_Base|false
686 + */
687 + private static function initWpFilesystem()
688 + {
689 + global $wp_filesystem;
690 + if (empty($wp_filesystem)) {
691 + require_once ABSPATH . 'wp-admin/includes/file.php';
692 + WP_Filesystem();
693 + }
694 + return $wp_filesystem;
695 + }
696 +
697 + /**
698 + * Write (overwrite) content to a file using WP_Filesystem.
699 + *
700 + * @param string $filePath Absolute path to the file.
701 + * @param string $content Content to write.
702 + * @return bool True on success, false on failure.
703 + */
704 + public static function writeFile($filePath, $content)
705 + {
706 + $fs = self::initWpFilesystem();
707 + if ($fs) {
708 + return $fs->put_contents($filePath, $content, FS_CHMOD_FILE);
709 + }
710 + // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable.
711 + return false !== file_put_contents($filePath, $content);
712 + }
713 +
714 + /**
715 + * Append content to a file using WP_Filesystem.
716 + * WP_Filesystem has no native append; we read + concatenate + write.
717 + *
718 + * @param string $filePath Absolute path to the file.
719 + * @param string $content Content to append.
720 + * @return bool True on success, false on failure.
721 + */
722 + public static function appendFile($filePath, $content)
723 + {
724 + $fs = self::initWpFilesystem();
725 + if ($fs) {
726 + $existing = $fs->exists($filePath) ? (string) $fs->get_contents($filePath) : '';
727 + return $fs->put_contents($filePath, $existing . $content, FS_CHMOD_FILE);
728 + }
729 + // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable.
730 + return false !== file_put_contents($filePath, $content, FILE_APPEND | LOCK_EX);
731 + }
732 +
733 + /**
734 + * Read and return the full content of a file using WP_Filesystem.
735 + *
736 + * @param string $filePath Absolute path to the file.
737 + * @return string File contents, or empty string if unreadable.
738 + */
739 + public static function readFile($filePath)
740 + {
741 + $fs = self::initWpFilesystem();
742 + if ($fs && $fs->exists($filePath)) {
743 + $content = $fs->get_contents($filePath);
744 + return false !== $content ? $content : '';
745 + }
746 + // Fallback.
747 + if (file_exists($filePath)) {
748 + $content = file_get_contents($filePath);
749 + return false !== $content ? $content : '';
750 + }
751 + return '';
752 + }
753 +
754 + public static function processRepeaterAttachment($repeaterKey, $fileKey, $fieldValue, $basePath, &$attachments)
755 + {
756 + if (!isset($fieldValue[$repeaterKey]) || !is_array($fieldValue[$repeaterKey])) {
757 + return;
758 + }
759 +
760 + foreach ($fieldValue[$repeaterKey] as $repeaterRow) {
761 + if (!isset($repeaterRow[$fileKey]) || empty($repeaterRow[$fileKey])) {
762 + continue;
763 + }
764 +
765 + $fileValue = $repeaterRow[$fileKey];
766 + self::addAttachmentFiles($fileValue, $basePath, $attachments);
767 + }
768 + }
769 +
770 + public static function processRegularAttachment($fileKey, $fieldValue, $basePath, &$attachments)
771 + {
772 + if (!isset($fieldValue[$fileKey]) || empty($fieldValue[$fileKey])) {
773 + return;
774 + }
775 +
776 + $fileValue = $fieldValue[$fileKey];
777 + self::addAttachmentFiles($fileValue, $basePath, $attachments);
778 + }
779 +
780 + private static function addAttachmentFiles($fileValue, $basePath, &$attachments)
781 + {
782 + $baseResolved = realpath($basePath);
783 + if (false === $baseResolved) {
784 + Log::debug_log([
785 + 'message' => 'FileHandler::addAttachmentFiles base path invalid',
786 + 'basePath' => $basePath,
787 + ]);
788 + return;
789 + }
790 +
791 + $addFile = static function ($candidate) use ($baseResolved, &$attachments) {
792 + $safeFile = is_string($candidate) ? trim($candidate) : '';
793 + if ('' === $safeFile) {
794 + return;
795 + }
796 + $path = $baseResolved . DIRECTORY_SEPARATOR . $safeFile;
797 + $resolved = realpath($path);
798 + if (false === $resolved || 0 !== strpos($resolved, $baseResolved . DIRECTORY_SEPARATOR)) {
799 + Log::debug_log([
800 + 'message' => 'FileHandler::addAttachmentFiles blocked path traversal',
801 + 'file' => $candidate,
802 + 'resolved' => $resolved,
803 + 'baseResolved' => $baseResolved,
804 + ]);
805 + return;
806 + }
807 + if (is_readable($resolved)) {
808 + $attachments[] = $resolved;
809 + }
810 + };
811 +
812 + if (is_array($fileValue)) {
813 + foreach ($fileValue as $singleFile) {
814 + $addFile($singleFile);
815 + }
816 + return;
817 + }
818 +
819 + $addFile($fileValue);
820 + }
821 +
822 + /**
823 + * Return file type by checking with mime type
824 + * @param string $mime
825 + * @return string
826 + */
827 + public static function getFileTypeByMime(string $mime)
828 + {
829 + $mime = strtolower($mime);
830 +
831 + if (preg_match('/^image\//', $mime)) {
832 + return 'image';
833 + }
834 +
835 + $compressed = [
836 + 'application/zip',
837 + 'application/x-rar-compressed',
838 + 'application/x-7z-compressed',
839 + 'application/gzip',
840 + 'application/x-tar',
841 + 'application/x-gtar',
842 + 'application/x-bzip2',
843 + 'application/x-archive',
844 + 'application/vnd.debian.binary-package',
845 + ];
846 + if (in_array($mime, $compressed, true)) {
847 + return 'compressed';
848 + }
849 +
850 + $presentation = [
851 + 'application/vnd.ms-powerpoint',
852 + 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
853 + 'application/vnd.oasis.opendocument.presentation',
854 + 'application/vnd.apple.keynote',
855 + ];
856 + if (in_array($mime, $presentation, true)) {
857 + return 'presentation';
858 + }
859 +
860 + $document = [
861 + 'application/pdf',
862 + 'application/msword',
863 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
864 + 'application/rtf',
865 + 'text/plain',
866 + 'application/vnd.oasis.opendocument.text',
867 + 'application/x-tex',
868 + 'text/rtf',
869 + ];
870 + if (in_array($mime, $document, true)) {
871 + return 'document';
872 + }
873 +
874 + $data = [
875 + 'text/csv',
876 + 'application/xml',
877 + 'text/xml',
878 + 'application/sql',
879 + 'application/vnd.ms-excel',
880 + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
881 + 'application/x-sqlite3',
882 + 'application/octet-stream', // generic binary (could be db files)
883 + ];
884 + if (in_array($mime, $data, true)) {
885 + return 'data';
886 + }
887 +
888 + if (preg_match('/^audio\//', $mime)) {
889 + return 'audio';
890 + }
891 +
892 + if (preg_match('/^video\//', $mime)) {
893 + return 'video';
894 + }
895 +
896 + return 'other';
897 + }
898 +
899 + /**
900 + * Return file type by checking with extension
901 + * @param string $extension
902 + * @return string
903 + */
904 + public static function getFileTypeByExtension($extension)
905 + {
906 + switch (strtolower($extension)) {
907 + case 'xbm':
908 + case 'tif':
909 + case 'pjp':
910 + case 'pjpeg':
911 + case 'svgz':
912 + case 'jpg':
913 + case 'jpeg':
914 + case 'ico':
915 + case 'tiff':
916 + case 'gif':
917 + case 'svg':
918 + case 'bmp':
919 + case 'png':
920 + case 'jfif':
921 + case 'webp':
922 + return 'image';
923 +
924 + case '7z':
925 + case 'arj':
926 + case 'deb':
927 + case 'pkg':
928 + case 'rar':
929 + case 'rpm':
930 + case 'gz':
931 + case 'z':
932 + case 'zip':
933 + return 'compressed';
934 +
935 + case 'key':
936 + case 'odp':
937 + case 'pps':
938 + case 'ppt':
939 + case 'pptx':
940 + return 'presentation';
941 +
942 + case '_rf_':
943 + case 'doc':
944 + case 'docx':
945 + case 'odt':
946 + case 'pdf':
947 + case 'rtf':
948 + case 'tex':
949 + case 'txt':
950 + case 'wks':
951 + case 'wps':
952 + case 'wpd':
953 + return 'document';
954 +
955 + case 'csv':
956 + case 'dat':
957 + case 'db':
958 + case 'dbf':
959 + case 'log':
960 + case 'mdb':
961 + case 'sav':
962 + case 'sql':
963 + case 'tar':
964 + case 'sqlite':
965 + case 'xml':
966 + return 'data';
967 +
968 + case 'opus':
969 + case 'flac':
970 + case 'webm':
971 + case 'weba':
972 + case 'wav':
973 + case 'ogg':
974 + case 'm4a':
975 + case 'mp3':
976 + case 'oga':
977 + case 'mid':
978 + case 'amr':
979 + case 'aiff':
980 + case 'wma':
981 + case 'au':
982 + case 'acc':
983 + case 'wpl':
984 + return 'audio';
985 +
986 + case 'ogm':
987 + case 'wmv':
988 + case 'mpg':
989 + case 'ogv':
990 + case 'mov':
991 + case 'asx':
992 + case 'mpeg':
993 + case 'mp4':
994 + case 'm4v':
995 + case 'avi':
996 + case '3gp':
997 + case 'flv':
998 + case 'mkv':
999 + case 'swf':
1000 + return 'video';
1001 + default:
1002 + return 'other';
276 1003 }
277 1004 }
278 1005 }