PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 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 All 139 releases
← All changes | includes/Core/Util/FileHandler.php +284 -30 3.1.0 → V3.3.2 View file →
@@ -121,14 +121,60 @@
121 121 }
122 122 return $file_upoalded;
123 123 }
124 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 +
125 144 public function deleteFiles($form_id, $entry_id, $files)
126 145 {
127 146 $_upload_dir = self::getEntriesFileUploadDir($form_id, $entry_id);
128 - foreach ($files as $name) {
129 - wp_delete_file($_upload_dir . DIRECTORY_SEPARATOR . $name);
147 + $resolvedBitformsUploadDir = realpath(BITFORMS_UPLOAD_DIR);
148 + $resolvedUploadDir = realpath($_upload_dir);
149 + if (false === $resolvedBitformsUploadDir || false === $resolvedUploadDir) {
150 + return;
130 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 + }
131 177 }
132 178
133 179 public static function getFileUploadError($code)
134 180 {
@@ -188,19 +234,23 @@
188 234 $tempDir = $upload_dir['basedir'] . '/bitforms/temp';
189 235 $destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR;
190 236 self::createIndexFile($destinationDir);
191 237
238 + $consumedFiles = [];
239 +
192 240 foreach ($submitted_data as $key => $data) {
193 241 if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) {
194 - $files = $data;
195 242 $fldData = $submitted_data[$key];
196 - $files = explode(',', $fldData);
197 - if (is_array($files) && count($files) > 0) {
198 - foreach ($files as $file) {
199 - 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;
200 250 }
201 - } else {
202 - self::fileCopy($tempDir, $destinationDir, trim($files));
251 + self::fileCopy($tempDir, $destinationDir, $safeFile);
252 + $consumedFiles[] = $safeFile;
203 253 }
204 254 if (!empty($files)) {
205 255 $submitted_data[$key] = $files;
206 256 }
@@ -205,39 +255,207 @@
205 255 $submitted_data[$key] = $files;
206 256 }
207 257 }
208 258 }
259 +
260 + self::cleanupTempUploads($tempDir, $consumedFiles);
261 +
262 + return $submitted_data;
263 + }
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 + {
209 275 $tempBase = realpath($tempDir);
210 - if (false !== $tempBase) {
211 - $tmpFiles = glob($tempBase . DIRECTORY_SEPARATOR . '*');
212 - foreach ((array) $tmpFiles as $tmpFile) {
213 - $resolved = realpath($tmpFile);
214 - if (false !== $resolved && 0 === strpos($resolved, $tempBase . DIRECTORY_SEPARATOR)) {
215 - wp_delete_file($resolved);
216 - }
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;
217 288 }
289 + if (\in_array(basename($resolved), $protected, true)) {
290 + continue;
291 + }
292 + wp_delete_file($resolved);
218 293 }
219 294
220 - return $submitted_data;
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 + }
221 319 }
222 320
223 - private function getByteSizeByUnit($sizeString)
321 + private function getByteSizeByUnit($sizeString, $base = 1024)
224 322 {
225 323 // split 2MB into 2 and MB
226 - $size = preg_replace('/[^0-9\.]/', '', $sizeString);
227 - $unit = preg_replace('/[^a-zA-Z]/', '', $sizeString);
324 + $size = (float) preg_replace('/[^0-9\.]/', '', (string) $sizeString);
325 + $unit = preg_replace('/[^a-zA-Z]/', '', (string) $sizeString);
228 326 $unit = strtolower($unit);
229 327 if ('kb' === $unit) {
230 - return $size * 1024;
328 + return $size * $base;
231 329 } elseif ('mb' === $unit) {
232 - return $size * 1024 * 1024;
330 + return $size * $base * $base;
233 331 } elseif ('gb' === $unit) {
234 - return $size * 1024 * 1024 * 1024;
332 + return $size * $base * $base * $base;
235 333 } else {
236 334 return $size;
237 335 }
238 336 }
239 337
338 + /**
339 + * How many files an Advanced File Upload field takes per submission, mirroring FilePond: a
340 + * single-file field holds one, and maxFiles only applies once multiple files are allowed.
341 + *
342 + * @param object|null $config field config
343 + *
344 + * @return int|null null when there is no limit
345 + */
346 + public static function getMaxFilesLimit($config)
347 + {
348 + if (!is_object($config)) {
349 + return null;
350 + }
351 + if (empty($config->allowMultiple)) {
352 + return 1;
353 + }
354 + $max = isset($config->maxFiles) && is_numeric($config->maxFiles) ? (int) $config->maxFiles : 0;
355 +
356 + return $max > 0 ? $max : null;
357 + }
358 +
359 + /**
360 + * Files submitted for one field: names already uploaded on select (comma list or array) plus
361 + * the files sent with the form.
362 + *
363 + * @param mixed $uploadedNames posted value of the field
364 + * @param mixed $postedFileNames $_FILES[field]['name']
365 + *
366 + * @return int
367 + */
368 + public static function countSubmittedFiles($uploadedNames, $postedFileNames)
369 + {
370 + $count = 0;
371 + foreach ([$uploadedNames, $postedFileNames] as $source) {
372 + $names = is_array($source) ? $source : (is_scalar($source) ? explode(',', (string) $source) : []);
373 + foreach ($names as $name) {
374 + if (is_scalar($name) && '' !== trim((string) $name)) {
375 + $count++;
376 + }
377 + }
378 + }
379 +
380 + return $count;
381 + }
382 +
383 + /**
384 + * The browser drops files past the limit, so more can only arrive from a crafted request.
385 + *
386 + * @param object|null $config field config
387 + * @param mixed $uploadedNames posted value of the field
388 + * @param mixed $postedFileNames $_FILES[field]['name']
389 + *
390 + * @return string error message, '' when within the limit
391 + */
392 + public static function getMaxFilesError($config, $uploadedNames, $postedFileNames)
393 + {
394 + $limit = self::getMaxFilesLimit($config);
395 + if (null === $limit || self::countSubmittedFiles($uploadedNames, $postedFileNames) <= $limit) {
396 + return '';
397 + }
398 +
399 + return sprintf(
400 + /* translators: %d: maximum number of files */
401 + _n('You can upload only %d file.', 'You can upload up to %d files.', $limit, 'bit-form'),
402 + $limit
403 + );
404 + }
405 +
406 + /**
407 + * getMaxFilesError() for a field of a stored form.
408 + *
409 + * @param int|string $formId
410 + * @param string $fieldKey
411 + * @param mixed $uploadedNames posted value of the field
412 + * @param mixed $postedFileNames $_FILES[field]['name']
413 + *
414 + * @return string
415 + */
416 + public static function getMaxFilesErrorForField($formId, $fieldKey, $uploadedNames, $postedFileNames)
417 + {
418 + $formContent = FormManager::getInstance($formId)->getFormContent();
419 + $field = is_object($formContent) && isset($formContent->fields->{$fieldKey}) ? $formContent->fields->{$fieldKey} : null;
420 + if (!is_object($field) || !isset($field->typ) || 'advanced-file-up' !== $field->typ) {
421 + return '';
422 + }
423 +
424 + return self::getMaxFilesError(isset($field->config) ? $field->config : null, $uploadedNames, $postedFileNames);
425 + }
426 +
427 + /**
428 + * Whether a MIME matches a `type/*` entry of the allow list, as FilePond accepts it. Markup and
429 + * script types never match a wildcard: they must be listed explicitly.
430 + *
431 + * @param mixed $mime detected MIME, lowercase
432 + * @param array $allowMimes allow-listed MIME entries, lowercase
433 + *
434 + * @return bool
435 + */
436 + public static function matchesWildcardMime($mime, $allowMimes)
437 + {
438 + $neverByWildcard = [
439 + 'text/html',
440 + 'application/xhtml+xml',
441 + 'text/javascript',
442 + 'application/javascript',
443 + 'application/x-javascript',
444 + 'application/ecmascript',
445 + 'text/ecmascript',
446 + ];
447 + if (!is_string($mime) || in_array($mime, $neverByWildcard, true)) {
448 + return false;
449 + }
450 + $slash = strpos($mime, '/');
451 + if (false === $slash || 0 === $slash) {
452 + return false;
453 + }
454 +
455 + return in_array(substr($mime, 0, $slash) . '/*', (array) $allowMimes, true);
456 + }
457 +
240 458 public function validation($field_key, $file_details, $form_id)
241 459 {
242 460 if (!function_exists('wp_check_filetype_and_ext')) {
243 461 require_once ABSPATH . 'wp-admin/includes/file.php';
@@ -250,8 +468,10 @@
250 468 $fieldType = $fieldDetail->typ;
251 469 $maxSizeDetails = [];
252 470 $allowFileTypes = [];
253 471 $maxSize = null;
472 + $minSize = null;
473 + $fieldConfig = isset($fieldDetail->config) && is_object($fieldDetail->config) ? $fieldDetail->config : null;
254 474 if ('file-up' === $fieldType) {
255 475 $allowFileTypes = !empty($fieldDetail->config->allowedFileType) ? $fieldDetail->config->allowedFileType : [];
256 476 if (!empty($fieldDetail->config->allowMaxSize)) {
257 477 if (!empty($fieldDetail->config->maxSize)) {
@@ -272,8 +492,14 @@
272 492 }
273 493 if (!empty($fieldDetail->config->maxTotalFileSize)) {
274 494 $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxTotalFileSize;
275 495 }
496 + // A client transform re-encodes images, so the uploaded copy can legitimately come out
497 + // smaller than the original the browser checked.
498 + if (!empty($fieldDetail->config->minFileSize) && empty($fieldDetail->config->allowImageTransform)) {
499 + // FilePond counts 1KB as 1000 bytes; a 1024 base would reject files the browser accepted.
500 + $minSize = $this->getByteSizeByUnit($fieldDetail->config->minFileSize, 1000);
501 + }
276 502 }
277 503 }
278 504 if (!empty($maxSizeDetails['maxSize'])) {
279 505 $maxSize = $this->getByteSizeByUnit($maxSizeDetails['maxSize']);
@@ -292,9 +518,13 @@
292 518 'tmp_name' => $file_details['tmp_name'][$rowIndex],
293 519 'error' => $file_details['error'][$rowIndex],
294 520 'size' => $file_details['size'][$rowIndex],
295 521 ];
296 - $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize);
522 + $countState = $this->maxFilesState($fieldType, $fieldConfig, $file);
523 + if (!empty($countState)) {
524 + return $countState;
525 + }
526 + $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize);
297 527 if (!empty($validateState) && !empty($validateState['message'])) {
298 528 return $validateState;
299 529 }
300 530 }
@@ -299,15 +529,36 @@
299 529 }
300 530 }
301 531 }
302 532 } else {
303 - return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize);
533 + $countState = $this->maxFilesState($fieldType, $fieldConfig, isset($file_details['name']) ? $file_details['name'] : null);
534 + if (!empty($countState)) {
535 + return $countState;
536 + }
537 + return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize);
304 538 }
305 539 return [];
306 540 }
307 541
308 - private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize)
542 + /**
543 + * @param string $fieldType
544 + * @param object|null $fieldConfig
545 + * @param mixed $postedFileNames $_FILES[field]['name'] (or one repeater row of it)
546 + *
547 + * @return array validation state, empty when within the limit
548 + */
549 + private function maxFilesState($fieldType, $fieldConfig, $postedFileNames)
309 550 {
551 + if ('advanced-file-up' !== $fieldType) {
552 + return [];
553 + }
554 + $message = self::getMaxFilesError($fieldConfig, null, $postedFileNames);
555 +
556 + return '' === $message ? [] : ['message' => $message, 'error_type' => 'file_count_error'];
557 + }
558 +
559 + private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize = null)
560 + {
310 561 $errorMessage = [
311 562 'message' => '',
312 563 'error_type' => '',
313 564 ];
@@ -322,9 +573,9 @@
322 573 'error' => $file_details['error'][$key],
323 574 'size' => $file_details['size'][$key],
324 575 ];
325 576 $totalSize += $fileInfo['size'];
326 - $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize);
577 + $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize, $minSize);
327 578 if (!empty($validateState)) {
328 579 return $validateState;
329 580 }
330 581 }
@@ -334,9 +585,9 @@
334 585 $errorMessage['error_type'] = 'file_size_error';
335 586 return $errorMessage;
336 587 }
337 588 } else {
338 - $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize);
589 + $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize, $minSize);
339 590 if (!empty($validateState)) {
340 591 return $validateState;
341 592 }
342 593 }
@@ -343,9 +594,9 @@
343 594
344 595 return $errorMessage;
345 596 }
346 597
347 - private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null)
598 + private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null, $minSize = null)
348 599 {
349 600 // 0) Basic sanity & transport integrity
350 601 if (!is_array($file) || empty($file['tmp_name'])) {
351 602 // return ['message' => __('No file uploaded.', 'bit-form'), 'error_type' => 'file_missing'];
@@ -373,8 +624,11 @@
373 624 }
374 625 if (!empty($maxSize) && $onDiskSize > $maxSize) {
375 626 return ['message' => __('File size is too large', 'bit-form'), 'error_type' => 'file_size_error'];
376 627 }
628 + if (!empty($minSize) && $onDiskSize < $minSize) {
629 + return ['message' => __('File size is too small', 'bit-form'), 'error_type' => 'file_size_error'];
630 + }
377 631
378 632 // 2) Determine ext + MIME using WP + finfo
379 633 $wpCheck = wp_check_filetype_and_ext($file['tmp_name'], $fileName); // ['ext'=>'jpg','type'=>'image/jpeg']
380 634 $fileExtension = strtolower((string)(empty($wpCheck['ext']) ? pathinfo($fileName, PATHINFO_EXTENSION) : $wpCheck['ext']));
@@ -435,9 +689,9 @@
435 689 $allowMimes = array_values(array_unique($allowMimes));
436 690
437 691 $hasAllowlist = (!empty($allowExts) || !empty($allowMimes));
438 692 $extMatch = in_array($fileExtension, $allowExts, true);
439 - $mimeMatch = in_array($detectedMime, $allowMimes, true);
693 + $mimeMatch = in_array($detectedMime, $allowMimes, true) || self::matchesWildcardMime($detectedMime, $allowMimes);
440 694
441 695 // 5) Require BOTH a legit WP mapping AND a match to the allowlist
442 696 $wpOk = ('' !== $wpExt && '' !== $wpType);
443 697