← 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; |