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 +760 -73 2.10.13.3.1 View file →
@@ -1,28 +1,22 @@
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
9 +if (!defined('ABSPATH')) {
10 + exit;
11 +}
7 12 final class FileHandler
8 13 {
9 14 public function rmrf($dir)
10 15 {
11 - if (is_dir($dir)) {
12 - $objects = scandir($dir);
13 - foreach ($objects as $object) {
14 - if ('.' !== $object && '..' !== $object) {
15 - if (is_dir($dir . DIRECTORY_SEPARATOR . $object) && !is_link($dir . DIRECTORY_SEPARATOR . $object)) {
16 - $this->rmrf($dir . DIRECTORY_SEPARATOR . $object);
17 - } else {
18 - unlink($dir . DIRECTORY_SEPARATOR . $object);
19 - }
20 - }
21 - }
22 - rmdir($dir);
23 - } else {
24 - unlink($dir);
16 + $fs = self::initWpFilesystem();
17 + if ($fs && $fs->exists($dir)) {
18 + $fs->delete($dir, true);
25 19 }
26 20 }
27 21
28 22 public function cpyr($source, $destination)
@@ -27,9 +21,9 @@
27 21
28 22 public function cpyr($source, $destination)
29 23 {
30 24 if (is_dir($source)) {
31 - mkdir($destination);
25 + wp_mkdir_p($destination);
32 26 // chmod($destination, 0744);
33 27 $objects = scandir($source);
34 28 foreach ($objects as $object) {
35 29 if ('.' !== $object && '..' !== $object) {
@@ -36,9 +30,9 @@
36 30 if (is_dir($source . DIRECTORY_SEPARATOR . $object) && !is_link($source . DIRECTORY_SEPARATOR . $object)) {
37 31 cpyr($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
38 32 } elseif (is_file($source . DIRECTORY_SEPARATOR . $object)) {
39 33 copy($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
40 - // chmod($destination. DIRECTORY_SEPARATOR .$object, 0644);
34 + // chmod($destination. DIRECTORY_SEPARATOR .$object, 0644);
41 35 } else {
42 36 symlink($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
43 37 }
44 38 }
@@ -49,17 +43,28 @@
49 43 }
50 44
51 45 public function moveUploadedFiles($file_details, $form_id, $entry_id)
52 46 {
47 + require_once ABSPATH . 'wp-admin/includes/file.php';
48 +
53 49 $file_upoalded = [];
54 - $_upload_dir = BITFORMS_UPLOAD_DIR . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR . $entry_id;
55 - 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 +
56 63 if (is_array($file_details['name'])) {
57 64 foreach ($file_details['name'] as $key => $value) {
58 - //check accepted filetype in_array($file_details['name'][$key], $supported_files) else \
59 65 if (!empty($value)) {
60 66 $fileNameCount = 1;
61 - // $file_upoalded[$key] = time()."_$value";
62 67 $file_upoalded[$key] = sanitize_file_name($value);
63 68 while (file_exists($_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[$key])) {
64 69 $fileNameWithSeparator = BITFORMS_BF_SEPARATOR . $fileNameCount;
65 70 $file_upoalded[$key] = sanitize_file_name(preg_replace('/(.[a-z A-Z 0-9]+)$/', "{$fileNameWithSeparator}$1", $value));
@@ -67,11 +72,22 @@
67 72 if (11 === $fileNameCount) {
68 73 break;
69 74 }
70 75 }
71 - $move_status = \move_uploaded_file($file_details['tmp_name'][$key], $_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[$key]);
72 - 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'])) {
73 87 unset($file_upoalded[$key]);
88 + } else {
89 + $file_upoalded[$key] = basename($upload_result['file']);
74 90 }
75 91 }
76 92 }
77 93 } else {
@@ -85,11 +101,22 @@
85 101 if (11 === $fileNameCount) {
86 102 break;
87 103 }
88 104 }
89 - $move_status = \move_uploaded_file($file_details['tmp_name'], $_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[0]);
90 - 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'])) {
91 116 unset($file_upoalded[0]);
117 + } else {
118 + $file_upoalded[0] = basename($upload_result['file']);
92 119 }
93 120 }
94 121 }
95 122 return $file_upoalded;
@@ -94,14 +121,60 @@
94 121 }
95 122 return $file_upoalded;
96 123 }
97 124
125 + public static function isSafeFileName($name)
126 + {
127 + if (!is_string($name)) {
128 + return false;
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;
142 + }
143 +
98 144 public function deleteFiles($form_id, $entry_id, $files)
99 145 {
100 - $_upload_dir = BITFORMS_UPLOAD_DIR . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR . $entry_id;
101 - foreach ($files as $name) {
102 - unlink($_upload_dir . DIRECTORY_SEPARATOR . $name);
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;
103 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 + }
104 177 }
105 178
106 179 public static function getFileUploadError($code)
107 180 {
@@ -119,13 +192,41 @@
119 192 }
120 193
121 194 public static function fileCopy($tmpdir, $destinationDir, $file)
122 195 {
123 - $tmpFile = $tmpdir . DIRECTORY_SEPARATOR . $file;
124 - $newFile = $destinationDir . DIRECTORY_SEPARATOR . $file;
125 - if (file_exists($tmpFile)) {
126 - copy($tmpFile, $newFile);
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;
127 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);
128 229 }
129 230
130 231 public static function tempDirToUploadDir($submitted_data, $fields, $formId, $entryID)
131 232 {
@@ -130,24 +231,26 @@
130 231 public static function tempDirToUploadDir($submitted_data, $fields, $formId, $entryID)
131 232 {
132 233 $upload_dir = wp_upload_dir();
133 234 $tempDir = $upload_dir['basedir'] . '/bitforms/temp';
134 - $destinationDir = BITFORMS_UPLOAD_DIR . DIRECTORY_SEPARATOR . $formId . DIRECTORY_SEPARATOR . $entryID . DIRECTORY_SEPARATOR;
135 - if (!is_dir($destinationDir)) {
136 - mkdir($destinationDir);
137 - }
235 + $destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR;
236 + self::createIndexFile($destinationDir);
138 237
238 + $consumedFiles = [];
239 +
139 240 foreach ($submitted_data as $key => $data) {
140 241 if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) {
141 - $files = $data;
142 242 $fldData = $submitted_data[$key];
143 - $files = explode(',', $fldData);
144 - if (is_array($files) && count($files) > 0) {
145 - foreach ($files as $file) {
146 - 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;
147 250 }
148 - } else {
149 - self::fileCopy($tempDir, $destinationDir, trim($files));
251 + self::fileCopy($tempDir, $destinationDir, $safeFile);
252 + $consumedFiles[] = $safeFile;
150 253 }
151 254 if (!empty($files)) {
152 255 $submitted_data[$key] = $files;
153 256 }
@@ -152,15 +255,70 @@
152 255 $submitted_data[$key] = $files;
153 256 }
154 257 }
155 258 }
156 - array_map('unlink', array_filter(
157 - (array) array_merge(glob("$tempDir/*"))
158 - ));
159 259
260 + self::cleanupTempUploads($tempDir, $consumedFiles);
261 +
160 262 return $submitted_data;
161 263 }
162 264
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 +
163 321 private function getByteSizeByUnit($sizeString)
164 322 {
165 323 // split 2MB into 2 and MB
166 324 $size = preg_replace('/[^0-9\.]/', '', $sizeString);
@@ -182,9 +340,9 @@
182 340 if (!function_exists('wp_check_filetype_and_ext')) {
183 341 require_once ABSPATH . 'wp-admin/includes/file.php';
184 342 }
185 343
186 - $formManager = new FormManager($form_id);
344 + $formManager = FormManager::getInstance($form_id);
187 345 $form_contents = $formManager->getFormContent();
188 346 $field_content_details = $form_contents->fields;
189 347 $fieldDetail = $field_content_details->{$field_key};
190 348 $fieldType = $fieldDetail->typ;
@@ -247,14 +405,14 @@
247 405
248 406 private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize)
249 407 {
250 408 $errorMessage = [
251 - 'message' => '',
252 - 'error_type'=> '',
409 + 'message' => '',
410 + 'error_type' => '',
253 411 ];
254 412 if (is_array($file_details['name'])) {
255 413 $totalSize = 0;
256 - foreach ($file_details['name'] as $key => $file) {
414 + foreach ($file_details['name'] as $key => $file) {
257 415 if (!empty($file)) {
258 416 $fileInfo = [
259 417 'name' => $file,
260 418 'type' => $file_details['type'][$key],
@@ -283,36 +441,565 @@
283 441
284 442 return $errorMessage;
285 443 }
286 444
287 - private function validateSingleFile($fieldType, $file, $allowTypes, $maxSize = null)
445 + private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null)
288 446 {
289 - $fileName = sanitize_file_name($file['name']);
290 - if (!empty($fileName)) {
291 - $fileSize = $file['size'];
292 - if (!empty($maxSize) && $fileSize > $maxSize) {
293 - return [
294 - 'message' => __('File size is too large', 'bit-form'),
295 - 'error_type'=> 'file_size_error',
296 - ];
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;
297 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 + }
298 531
299 - $fileExtension = pathinfo($fileName, PATHINFO_EXTENSION);
300 - $fileExtAllowedByWp = wp_check_filetype_and_ext($file['tmp_name'], $fileName);
301 - $isAllowedFileType = in_array('.' . $fileExtension, $allowTypes);
302 - if ('advanced-file-up' === $fieldType && !empty($allowTypes)) {
303 - if (function_exists('mime_content_type')) {
304 - $fileMimeType = mime_content_type($file['tmp_name']);
305 - } else {
306 - $fileMimeType = $fileExtAllowedByWp['type'];
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'];
545 + }
546 + } else {
547 + if (!$wpOk) {
548 + return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
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'];
307 599 }
308 - $isAllowedFileType = in_array($fileMimeType, $allowTypes);
309 600 }
310 - if ((!empty($allowTypes) && !$isAllowedFileType) || (empty($allowTypes) && empty($fileExtAllowedByWp['ext']))) {
311 - return [
312 - 'message' => __(($fileExtension ? ".{$fileExtension}" : 'empty') . ' file extension is not allowed', 'bit-form'),
313 - 'error_type'=> 'file_type_error',
314 - ];
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 + }
315 677 }
316 678 }
679 + return false;
317 680 }
318 -}
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';
1003 + }
1004 + }
1005 +}