PluginProbe
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce / 3.3.0
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce v3.3.0
3.3.0 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 trunk 1.0.4 1.1.0 1.1.0.1 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 All 162 releases
← All changes | modules/checkout/classes/class-cartflows-checkout-ajax.php +206 -11 3.1.2 → 3.3.0 View file →
@@ -304,8 +304,18 @@
304 304 array( 'error' => __( 'Nonce validation failed.', 'cartflows' ) )
305 305 );
306 306 }
307 307
308 + // Security: this endpoint is nopriv, so bind every upload to a real checkout step's file field to prevent arbitrary writes.
309 + $checkout_id = empty( $_POST['checkout_id'] ) ? 0 : absint( $_POST['checkout_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
310 + $field_key = empty( $_POST['field_key'] ) ? '' : sanitize_text_field( wp_unslash( $_POST['field_key'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
311 +
312 + if ( ! $this->is_valid_file_upload_field( $checkout_id, $field_key ) ) {
313 + wp_send_json_error(
314 + array( 'error' => __( 'This upload is not associated with a valid checkout file field.', 'cartflows' ) )
315 + );
316 + }
317 +
308 318 if ( empty( $_FILES['wcf_checkout_file']['tmp_name'] ) ) {
309 319 wp_send_json_error(
310 320 array( 'error' => __( 'No file uploaded.', 'cartflows' ) )
311 321 );
@@ -352,8 +362,63 @@
352 362 );
353 363 }
354 364
355 365 /**
366 + * Resolve a checkout field from step meta, using the same accessors and gates as the renderer.
367 + *
368 + * @since 3.2.1
369 + *
370 + * @param int $checkout_id Checkout step post ID.
371 + * @param string $field_key Billing/shipping field key.
372 + * @return array|null The field definition when it is rendered on a checkout step, else null.
373 + */
374 + private function get_upload_field( $checkout_id, $field_key ) {
375 +
376 + if ( empty( $checkout_id ) || empty( $field_key ) ) {
377 + return null;
378 + }
379 +
380 + // Must be a checkout step specifically (post type is shared across all step types).
381 + if ( CARTFLOWS_STEP_POST_TYPE !== get_post_type( $checkout_id ) || 'checkout' !== get_post_meta( $checkout_id, 'wcf-step-type', true ) ) {
382 + return null;
383 + }
384 +
385 + // Custom checkout fields must be enabled, mirroring the render path gate.
386 + if ( ! _is_wcf_meta_custom_checkout( $checkout_id ) ) {
387 + return null;
388 + }
389 +
390 + $field_type = ( 0 === strpos( $field_key, 'shipping_' ) ) ? 'shipping' : 'billing';
391 + $saved_fields = wcf()->options->get_checkout_meta_value( $checkout_id, 'wcf_field_order_' . $field_type );
392 +
393 + if ( ! is_array( $saved_fields ) || empty( $saved_fields[ $field_key ] ) || ! is_array( $saved_fields[ $field_key ] ) ) {
394 + return null;
395 + }
396 +
397 + return $saved_fields[ $field_key ];
398 + }
399 +
400 + /**
401 + * Confirm an upload targets a rendered checkout file field.
402 + *
403 + * @since 3.2.1
404 + *
405 + * @param int $checkout_id Checkout step post ID.
406 + * @param string $field_key Billing/shipping field key.
407 + * @return bool True when the field is an enabled file-upload field on a checkout step.
408 + */
409 + private function is_valid_file_upload_field( $checkout_id, $field_key ) {
410 +
411 + $field = $this->get_upload_field( $checkout_id, $field_key );
412 +
413 + if ( null === $field || empty( $field['enabled'] ) ) {
414 + return false;
415 + }
416 +
417 + return isset( $field['type'] ) && 'file' === $field['type'];
418 + }
419 +
420 + /**
356 421 * Retrieve file size and type restrictions from field settings.
357 422 *
358 423 * @since 2.2.2
359 424 *
@@ -366,22 +431,17 @@
366 431
367 432 $field_key = empty( $_POST['field_key'] ) ? '' : sanitize_text_field( wp_unslash( $_POST['field_key'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
368 433 $checkout_id = empty( $_POST['checkout_id'] ) ? 0 : absint( $_POST['checkout_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
369 434
370 - if ( empty( $field_key ) || empty( $checkout_id ) ) {
371 - return compact( 'max_size', 'extensions' );
372 - }
435 + $field = $this->get_upload_field( $checkout_id, $field_key );
373 436
374 - $field_type = ( 0 === strpos( $field_key, 'shipping_' ) ) ? 'shipping' : 'billing';
375 - $saved_fields = get_post_meta( $checkout_id, 'wcf_field_order_' . $field_type, true );
376 -
377 - if ( ! is_array( $saved_fields ) || empty( $saved_fields[ $field_key ] ) || ! is_array( $saved_fields[ $field_key ] ) ) {
437 + if ( null === $field ) {
378 438 return compact( 'max_size', 'extensions' );
379 439 }
380 440
381 - $custom_attributes = empty( $saved_fields[ $field_key ]['custom_attributes'] ) || ! is_array( $saved_fields[ $field_key ]['custom_attributes'] )
441 + $custom_attributes = empty( $field['custom_attributes'] ) || ! is_array( $field['custom_attributes'] )
382 442 ? array()
383 - : $saved_fields[ $field_key ]['custom_attributes'];
443 + : $field['custom_attributes'];
384 444
385 445 if ( empty( $custom_attributes ) ) {
386 446 return compact( 'max_size', 'extensions' );
387 447 }
@@ -416,8 +476,131 @@
416 476 return array_values( array_filter( $extensions ) );
417 477 }
418 478
419 479 /**
480 + * Absolute path and URL of the protected checkout upload directory.
481 + *
482 + * @since 3.2.1
483 + *
484 + * @return array Path and URL checkout uploads are stored under.
485 + */
486 + private function get_checkout_upload_dir() {
487 +
488 + $upload_dir = wp_upload_dir();
489 +
490 + return array(
491 + 'path' => trailingslashit( $upload_dir['basedir'] ) . 'cartflows-checkout',
492 + 'url' => trailingslashit( $upload_dir['baseurl'] ) . 'cartflows-checkout',
493 + );
494 + }
495 +
496 + /**
497 + * Point wp_handle_upload() at the protected checkout upload directory.
498 + *
499 + * @since 3.2.1
500 + *
501 + * @param array $dirs Upload directory data.
502 + * @return array
503 + */
504 + public function set_checkout_upload_dir( $dirs ) {
505 +
506 + $checkout_dir = $this->get_checkout_upload_dir();
507 +
508 + $dirs['path'] = $checkout_dir['path'];
509 + $dirs['url'] = $checkout_dir['url'];
510 + $dirs['subdir'] = '';
511 +
512 + return $dirs;
513 + }
514 +
515 + /**
516 + * Create the checkout upload directory with a deny-all .htaccess and an index.php.
517 + *
518 + * Security: this endpoint is nopriv and the files are shopper-supplied, so the directory
519 + * that holds them must never execute or list what it contains.
520 + *
521 + * @since 3.2.1
522 + *
523 + * @return void
524 + */
525 + private function protect_checkout_upload_dir() {
526 +
527 + $checkout_dir = $this->get_checkout_upload_dir();
528 +
529 + if ( ! wp_mkdir_p( $checkout_dir['path'] ) ) {
530 + return;
531 + }
532 +
533 + if ( ! function_exists( 'WP_Filesystem' ) ) {
534 + require_once ABSPATH . 'wp-admin/includes/file.php';
535 + }
536 +
537 + if ( ! WP_Filesystem() ) {
538 + return;
539 + }
540 +
541 + global $wp_filesystem;
542 +
543 + $htaccess = trailingslashit( $checkout_dir['path'] ) . '.htaccess';
544 +
545 + if ( ! $wp_filesystem->exists( $htaccess ) ) {
546 + $rules = "# Generated by CartFlows - checkout uploads must never be executed or served as code.\n";
547 + $rules .= "<IfModule mod_php.c>\n\tphp_flag engine off\n</IfModule>\n";
548 + $rules .= "<IfModule mod_php7.c>\n\tphp_flag engine off\n</IfModule>\n";
549 + $rules .= "<IfModule mod_php8.c>\n\tphp_flag engine off\n</IfModule>\n";
550 + $rules .= "<FilesMatch \"\\.(?i:php|phar|phtml|php[0-9]|phps|pl|py|cgi|shtml)$\">\n";
551 + $rules .= "\t<IfModule mod_authz_core.c>\n\t\tRequire all denied\n\t</IfModule>\n";
552 + $rules .= "\t<IfModule !mod_authz_core.c>\n\t\tDeny from all\n\t</IfModule>\n";
553 + $rules .= "</FilesMatch>\n";
554 +
555 + $wp_filesystem->put_contents( $htaccess, $rules, FS_CHMOD_FILE );
556 + }
557 +
558 + $index = trailingslashit( $checkout_dir['path'] ) . 'index.php';
559 +
560 + if ( ! $wp_filesystem->exists( $index ) ) {
561 + $wp_filesystem->put_contents( $index, "<?php\n// Silence is golden.\n", FS_CHMOD_FILE );
562 + }
563 + }
564 +
565 + /**
566 + * Re-encode an uploaded image so an appended payload cannot survive inside it.
567 + *
568 + * Security: a valid GIF or JPEG with PHP appended passes wp_check_filetype_and_ext();
569 + * re-encoding keeps only the image data. An image that cannot be re-encoded is deleted.
570 + *
571 + * @since 3.2.1
572 + *
573 + * @param string $path Absolute path of the uploaded file.
574 + * @return bool True when the stored file is safe to keep.
575 + */
576 + private function reencode_uploaded_image( $path ) {
577 +
578 + $filetype = wp_check_filetype( $path );
579 +
580 + if ( empty( $filetype['type'] ) || 0 !== strpos( $filetype['type'], 'image/' ) ) {
581 + return true;
582 + }
583 +
584 + $editor = wp_get_image_editor( $path );
585 +
586 + if ( is_wp_error( $editor ) ) {
587 + wp_delete_file( $path );
588 + return false;
589 + }
590 +
591 + $saved = $editor->save( $path );
592 +
593 + if ( is_wp_error( $saved ) ) {
594 + wp_delete_file( $path );
595 + return false;
596 + }
597 +
598 + return true;
599 + }
600 +
601 +
602 + /**
420 603 * Move the uploaded file into the WordPress uploads directory.
421 604 *
422 605 * @since 2.2.2
423 606 *
@@ -425,11 +608,15 @@
425 608 * @return array Upload result.
426 609 */
427 610 private function move_uploaded_file( array $file ) {
428 611
429 - $upload_dir = wp_upload_dir();
430 - $file['name'] = wp_unique_filename( $upload_dir['path'], 'wcf_' . wp_generate_uuid4() . '.' . $file['ext'] );
612 + $this->protect_checkout_upload_dir();
431 613
614 + $checkout_dir = $this->get_checkout_upload_dir();
615 + $file['name'] = wp_unique_filename( $checkout_dir['path'], 'wcf_' . wp_generate_uuid4() . '.' . $file['ext'] );
616 +
617 + add_filter( 'upload_dir', array( $this, 'set_checkout_upload_dir' ) );
618 +
432 619 $result = wp_handle_upload(
433 620 $file,
434 621 array(
435 622 'test_form' => false,
@@ -436,11 +623,19 @@
436 623 'mimes' => wp_get_mime_types(),
437 624 )
438 625 );
439 626
627 + remove_filter( 'upload_dir', array( $this, 'set_checkout_upload_dir' ) );
628 +
440 629 if ( isset( $result['error'] ) ) {
441 630 wp_send_json_error(
442 631 array( 'error' => esc_html( $result['error'] ) )
632 + );
633 + }
634 +
635 + if ( ! $this->reencode_uploaded_image( $result['file'] ) ) {
636 + wp_send_json_error(
637 + array( 'error' => __( 'This image could not be processed. Please upload a different file.', 'cartflows' ) )
443 638 );
444 639 }
445 640
446 641 return $result;