PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
← All changes | includes/admin/hooks/class-wdkit-widget-ajax.php +169 -21 2.6.3 → 2.7.0 View file →
@@ -169,9 +169,9 @@
169 169 *
170 170 * @since 1.1.4
171 171 */
172 172 public function wdkit_public_download_widget() {
173 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
173 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
174 174 $data = json_decode( stripslashes( $data ) );
175 175
176 176 $api_type = isset( $data->api_type ) ? sanitize_text_field( $data->api_type ) : 'widget/download';
177 177
@@ -227,10 +227,15 @@
227 227 );
228 228 wp_die();
229 229 }
230 230
231 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $widget_id;
232 - $file_name = str_replace( ' ', '_', $title ) . '_' . $widget_id;
231 + // Canonical helpers replace spaces BEFORE sanitize_file_name(). $title above is
232 + // already sanitized, so spaces had become hyphens and the underscore pass did
233 + // nothing — a multi-word title wrote "My-Widget_id.json" while the builder's
234 + // save path writes "My_Widget_id.php". The loader pairs them by swapping .php
235 + // for .json, so the widget never registered (ClickUp 86d41cck5).
236 + $folder_name = wdesignkit_widget_folder_name( $title, $widget_id );
237 + $file_name = wdesignkit_widget_file_name( $title, $widget_id );
233 238
234 239 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
235 240
236 241 if ( ! is_dir( $builder_type_path . $folder_name ) ) {
@@ -253,15 +258,25 @@
253 258 if ( ! empty( $img_url ) ) {
254 259 // SSRF guard (CWE-918): validate the resolved host before fetching.
255 260 $img_body = wdesignkit_safe_remote_get( $img_url );
256 261 if ( ! is_wp_error( $img_body ) ) {
257 - $img_ext = pathinfo( $img_url )['extension'];
258 - $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
262 + // The remote extension was written verbatim here, so a ".php" image URL
263 + // in a cloud response landed executable PHP in the builder directory
264 + // (CWE-434, ClickUp 86d41cczd). '' means the bytes are not an image.
265 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
259 266
260 - $json_data->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
267 + if ( '' !== $img_ext ) {
268 + $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
269 +
270 + $json_data->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
271 + }
261 272 }
262 273 }
263 274
275 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
276 + wdesignkit_invalidate_widget_registry( $builder );
277 + }
278 +
264 279 $response = (object) array(
265 280 'message' => ! empty( $response['message'] ) ? $response['message'] : '',
266 281 'description' => ! empty( $response['description'] ) ? $response['description'] : '',
267 282 'success' => ! empty( $response['success'] ) ? $response['success'] : false,
@@ -377,8 +392,24 @@
377 392 *
378 393 * @since 1.1.4
379 394 */
380 395 public function wkit_create_widget() {
396 + // Site-level opt-out for generated widget PHP (ClickUp 86d41zavc). Defaults to allowed,
397 + // so this changes nothing unless an operator explicitly filters it off:
398 + // add_filter( 'wdesignkit_allow_widget_php_write', '__return_false' );
399 + // See wdesignkit_widget_php_write_allowed() for why this is a dedicated filter rather
400 + // than a DISALLOW_FILE_MODS / DISALLOW_FILE_EDIT check.
401 + if ( function_exists( 'wdesignkit_widget_php_write_allowed' ) && ! wdesignkit_widget_php_write_allowed() ) {
402 + wp_send_json(
403 + (object) array(
404 + 'success' => false,
405 + 'message' => esc_html__( 'File modifications are disabled on this site', 'wdesignkit' ),
406 + 'description' => esc_html__( 'Generated widget PHP writes have been turned off via the wdesignkit_allow_widget_php_write filter.', 'wdesignkit' ),
407 + )
408 + );
409 + wp_die();
410 + }
411 +
381 412 $image = '';
382 413 if ( isset( $_FILES ) && ! empty( $_FILES ) && isset( $_FILES['image'] ) && ! empty( $_FILES['image'] ) ) {
383 414 $image = Wdkit_Data_Hooks::get_super_global_value( $_FILES, 'image' );
384 415 }
@@ -387,9 +418,9 @@
387 418 if ( isset( $_FILES ) && ! empty( $_FILES ) && isset( $_FILES['icon'] ) && ! empty( $_FILES['icon'] ) ) {
388 419 $icon = Wdkit_Data_Hooks::get_super_global_value( $_FILES, 'icon' );
389 420 }
390 421
391 - $data = ! empty( $_POST['value'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'value', 'cr_widget' ) : '';
422 + $data = ! empty( $_POST['value'] ) ? $this->wdkit_extract_post_field( $_POST, 'value', 'cr_widget' ) : '';
392 423 // $data = ! empty( $data ) ? stripslashes( $data ) : '';
393 424 $return = ! empty( $data ) ? json_decode( $data ) : '';
394 425
395 426 $all_val = ! empty( $return ) ? $return : '';
@@ -447,15 +478,48 @@
447 478 wp_send_json( $responce );
448 479 wp_die();
449 480 }
450 481
451 - $builder_type_path = trailingslashit( WDKIT_BUILDER_PATH ) . trailingslashit( $widget_type );
452 - $widget_file_url = $builder_type_path . $folder_name;
482 + // $file_name, $folder_name and $widget_type arrive from the request body and were only
483 + // passed through sanitize_text_field(), which leaves "../../../../plugins/hello"
484 + // untouched — so this handler would write executable PHP anywhere the web user can
485 + // reach, outside the builder directory and its .htaccess deny rule. The sibling
486 + // download handler above has had these guards since 2.6.4; this one was missed
487 + // (CWE-22, ClickUp 86d41cckh).
488 + $safe_path = wdesignkit_widget_path_guard( $widget_type, $folder_name, $file_name );
489 + if ( false === $safe_path ) {
490 + wp_send_json(
491 + (object) array(
492 + 'success' => false,
493 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
494 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
495 + )
496 + );
497 + wp_die();
498 + }
453 499
500 + $widget_type = $safe_path['builder'];
501 + $folder_name = $safe_path['folder'];
502 + $file_name = $safe_path['file'];
503 + $builder_type_path = trailingslashit( $safe_path['builder_dir'] );
504 + $widget_file_url = $safe_path['dir'];
505 +
454 506 if ( ! is_dir( $widget_file_url ) ) {
455 507 wp_mkdir_p( $widget_file_url );
456 508 }
457 509
510 + // Re-check the created directory: the component guard above cannot see a symlink.
511 + if ( ! wdesignkit_path_inside_builder_dir( $widget_file_url ) ) {
512 + wp_send_json(
513 + (object) array(
514 + 'success' => false,
515 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
516 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
517 + )
518 + );
519 + wp_die();
520 + }
521 +
458 522 include_once ABSPATH . 'wp-admin/includes/file.php';
459 523 \WP_Filesystem();
460 524 global $wp_filesystem;
461 525 $widget_folder_u_r_l = trailingslashit( $widget_file_url ) . $file_name;
@@ -588,9 +652,13 @@
588 652
589 653 }
590 654 } elseif ( ! empty( $old_widget ) ) {
591 655 $img_url = $data->widget_data->widgetdata->w_image;
592 - $img_ext = ! empty( pathinfo( $img_url )['extension'] ) ? pathinfo( $img_url )['extension'] : '';
656 + // Copy of an existing local thumbnail, so there are no downloaded bytes to verify —
657 + // the allowlist alone keeps the stored w_image extension from naming the copy
658 + // something executable (ClickUp 86d41cczd). A non-image extension yields 'png',
659 + // which then fails the file_exists() check below and copies nothing.
660 + $img_ext = wdesignkit_safe_image_extension( $img_url );
593 661
594 662 if ( ! empty( $img_ext ) ) {
595 663 $old_widget_folder = str_replace( ' ', '-', $old_widget );
596 664 $old_widget_file = str_replace( ' ', '_', $old_widget );
@@ -613,10 +681,29 @@
613 681 $d_img_url = $d_image;
614 682 // SSRF guard (CWE-918): validate the resolved host before fetching.
615 683 $img_body = wdesignkit_safe_remote_get( $d_img_url );
616 684 if ( ! is_wp_error( $img_body ) ) {
617 - $img_ext = pathinfo( $d_img_url )['extension'];
618 - $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$widget_type/$folder_name/$file_name.$img_ext", $img_body['body'] );
685 + // The on-disk extension must never be taken verbatim from a remote URL (CWE-434) —
686 + // a poisoned response pointing at a ".php" path would otherwise write executable
687 + // PHP into the builder directory. Restrict to an image allowlist, falling back to
688 + // png, and verify the downloaded bytes' real MIME type before writing.
689 + $allowed_img_exts = array( 'jpg', 'jpeg', 'png', 'webp' );
690 + $img_ext = strtolower( pathinfo( $d_img_url, PATHINFO_EXTENSION ) );
691 + $img_ext = in_array( $img_ext, $allowed_img_exts, true ) ? $img_ext : 'png';
692 +
693 + $tmp_check = wp_check_filetype_and_ext( '', "temp.$img_ext" );
694 + $mime_ok = ! empty( $tmp_check['ext'] ) && in_array( $tmp_check['ext'], $allowed_img_exts, true );
695 +
696 + $finfo = function_exists( 'finfo_open' ) ? finfo_open( FILEINFO_MIME_TYPE ) : false;
697 + $real_mime = $finfo ? finfo_buffer( $finfo, $img_body['body'] ) : '';
698 + if ( $finfo ) {
699 + finfo_close( $finfo );
700 + }
701 + $allowed_mimes = array( 'image/jpeg', 'image/png', 'image/webp' );
702 +
703 + if ( $mime_ok && ( ! $real_mime || in_array( $real_mime, $allowed_mimes, true ) ) ) {
704 + $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$widget_type/$folder_name/$file_name.$img_ext", $img_body['body'] );
705 + }
619 706 }
620 707 }
621 708
622 709 if ( ! empty( $function_call ) && 'import' !== $function_call && ! empty( $old_folder ) && strtolower( $old_folder ) !== strtolower( $folder_name ) && is_dir( $builder_type_path . $old_folder ) ) {
@@ -632,8 +719,12 @@
632 719 rename( $old_path, $new_path );
633 720 }
634 721 }
635 722
723 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
724 + wdesignkit_invalidate_widget_registry( $widget_type );
725 + }
726 +
636 727 $responce = array(
637 728 'message' => esc_html__( 'Updates Saved!', 'wdesignkit' ),
638 729 'description' => esc_html__( 'Your changes have been saved successfully.', 'wdesignkit' ),
639 730 'success' => true,
@@ -722,10 +813,15 @@
722 813 wp_send_json( $responce );
723 814 wp_die();
724 815 }
725 816
726 - $folder_name = str_replace( ' ', '-', $widget_name );
727 - $file_name = str_replace( ' ', '_', $widget_name );
817 + // .wdk zip import. Same ordering bug as the download path above: $widget_name
818 + // is already sanitize_file_name()'d, so the underscore pass was a no-op and a
819 + // multi-word title landed as "My-Widget_id" where the loader expects
820 + // "My_Widget_id" (ClickUp 86d41cck5). The helpers include the id, so the
821 + // "_{$widget_id}" that used to be appended at each use site is gone.
822 + $folder_name = wdesignkit_widget_folder_name( $widget_name, $widget_id );
823 + $file_name = wdesignkit_widget_file_name( $widget_name, $widget_id );
728 824
729 825 $allowed_builders = array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' );
730 826 if ( ! in_array( $widget_type, $allowed_builders, true ) ) {
731 827 $responce = (object) array(
@@ -740,9 +836,9 @@
740 836
741 837 if ( ! is_dir( WDKIT_BUILDER_PATH . "/{$widget_type}" ) ) {
742 838 wp_mkdir_p( WDKIT_BUILDER_PATH . "/{$widget_type}" );
743 839 }
744 - $file_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}_{$widget_id}";
840 + $file_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}";
745 841
746 842 // Realpath containment — ensure the resolved builder dir stays inside WDKIT_BUILDER_PATH.
747 843 $real_base = realpath( WDKIT_BUILDER_PATH );
748 844 $real_type = realpath( WDKIT_BUILDER_PATH . "/{$widget_type}" );
@@ -764,12 +860,16 @@
764 860 if ( ! is_dir( $file_path ) ) {
765 861 wp_mkdir_p( $file_path );
766 862 }
767 863
768 - $wp_filesystem->put_contents( "{$file_path}/{$file_name}_{$widget_id}.$img_ext", $imageData );
864 + $wp_filesystem->put_contents( "{$file_path}/{$file_name}.$img_ext", $imageData );
769 865 }
770 866 }
771 867
868 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
869 + wdesignkit_invalidate_widget_registry( $widget_type );
870 + }
871 +
772 872 $responce = (object) array(
773 873 'success' => true,
774 874 'message' => esc_html__( 'Widget imported', 'wdesignkit' ),
775 875 'description' => esc_html__( 'Widget imported successfully', 'wdesignkit' ),
@@ -799,10 +899,13 @@
799 899 public function wkit_export_widget() {
800 900 $data = isset( $_POST['info'] ) ? wp_unslash( $_POST['info'] ) : '';
801 901 $data = json_decode( stripslashes( $data ) );
802 902
803 - $widget_name_temp = isset( $data->widget_name ) ? $data->widget_name : '';
804 - $widget_type = isset( $data->widget_type ) ? sanitize_text_field( $data->widget_type ) : '';
903 + // Sanitize as filenames before they are used to build the export path
904 + // (CWE-22). sanitize_file_name()/sanitize_key() strip path separators
905 + // and dots so a crafted widget_name/widget_type cannot escape WDKIT_BUILDER_PATH.
906 + $widget_name_temp = isset( $data->widget_name ) ? sanitize_file_name( $data->widget_name ) : '';
907 + $widget_type = isset( $data->widget_type ) ? sanitize_key( $data->widget_type ) : '';
805 908
806 909 $widget_name = str_replace( ' ', '_', $widget_name_temp );
807 910 $folder = str_replace( ' ', '-', $widget_name_temp );
808 911 $unique_version = $this->wdkit_generate_unique_id();
@@ -821,8 +924,23 @@
821 924
822 925 $downlod_path = WDKIT_BUILDER_PATH . "/{$widget_type}/";
823 926 $new_path = "{$downlod_path}/{$folder}/{$widget_name}";
824 927
928 + // Realpath containment — ensure the resolved widget path stays inside WDKIT_BUILDER_PATH.
929 + $real_base = realpath( WDKIT_BUILDER_PATH );
930 + $real_new = realpath( "$new_path.json" );
931 + if ( ! $real_base || ! $real_new || strpos( $real_new, $real_base . DIRECTORY_SEPARATOR ) !== 0 ) {
932 + $result = (object) array(
933 + 'success' => false,
934 + 'url' => '',
935 + 'message' => esc_html__( 'Widget Exported Fail', 'wdesignkit' ),
936 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
937 + );
938 +
939 + wp_send_json( $result );
940 + wp_die();
941 + }
942 +
825 943 $download_url = WDKIT_SERVER_PATH . "/{$widget_type}/{$widget_name}.zip";
826 944 $zip = new ZipArchive();
827 945 $tmp_file = "{$downlod_path}{$widget_name}.zip";
828 946
@@ -897,15 +1015,43 @@
897 1015
898 1016 $dir_name = isset( $data->name ) ? sanitize_text_field( $data->name ) : '';
899 1017 $widget_type = isset( $data->builder ) ? sanitize_text_field( $data->builder ) : '';
900 1018
901 - $dir = WDKIT_BUILDER_PATH . "/{$widget_type}/{$dir_name}";
1019 + // Same missing guards as wkit_create_widget, but the sink here is a RECURSIVE rmdir:
1020 + // sanitize_text_field() leaves traversal intact, so a crafted name/builder could
1021 + // delete any directory the web user owns (CWE-22, ClickUp 86d41cckh). Require a
1022 + // builder from the allowlist and a name that is a single path segment.
1023 + $safe_path = wdesignkit_widget_path_guard( $widget_type, $dir_name );
1024 + if ( false === $safe_path || '' === $safe_path['folder'] ) {
1025 + wp_send_json(
1026 + (object) array(
1027 + 'success' => false,
1028 + 'message' => esc_html__( 'Widget Not Deleted', 'wdesignkit' ),
1029 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
1030 + )
1031 + );
1032 + wp_die();
1033 + }
902 1034
1035 + $widget_type = $safe_path['builder'];
1036 + $dir = $safe_path['dir'];
1037 +
903 1038 require_once ABSPATH . 'wp-admin/includes/file.php';
904 1039 global $wp_filesystem;
905 1040 WP_Filesystem();
906 - $wp_filesystem->rmdir( $dir, true );
907 1041
1042 + // Remove only a directory that resolves inside the builder path — the containment
1043 + // re-check also rules out a symlinked folder. A missing directory is not an error:
1044 + // a 'plugin_server' delete legitimately has no local folder, and the server call
1045 + // above has already succeeded, so skip the removal and report success as before.
1046 + if ( is_dir( $dir ) && wdesignkit_path_inside_builder_dir( $dir ) ) {
1047 + $wp_filesystem->rmdir( $dir, true );
1048 + }
1049 +
1050 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
1051 + wdesignkit_invalidate_widget_registry( $widget_type );
1052 + }
1053 +
908 1054 if ( 'plugin_server' === $delete_type ) {
909 1055 wp_send_json( $response['data'] );
910 1056 wp_die();
911 1057 } else {
@@ -1277,9 +1423,9 @@
1277 1423 * @param string $type store text data.
1278 1424 * @param string $condition store text data.
1279 1425 */
1280 1426
1281 - public function wdkit_sanitizer_bypass( $data, $type, $condition = 'none' ) {
1427 + public function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
1282 1428
1283 1429 if ( 'none' === $condition ) {
1284 1430 return $data[ $type ];
1285 1431 } elseif ( 'cr_widget' === $condition ) {
@@ -1284,8 +1430,10 @@
1284 1430 return $data[ $type ];
1285 1431 } elseif ( 'cr_widget' === $condition ) {
1286 1432 return base64_decode($data[ $type ]);
1287 1433 }
1434 +
1435 + return null;
1288 1436 }
1289 1437
1290 1438 /**
1291 1439 * Parse args $_POST