From 7a31cc01c684b1faed2490e357b883adef0aabf1 Mon Sep 17 00:00:00 2001 From: deepench Date: Mon, 12 Oct 2026 11:48:07 +0100 Subject: [PATCH 1/3] Fix - Unauthenticated arbitrary file deletion via upload field cleanup The disabled-entries cleanup rebuilt the delete path from an attacker supplied file URL with a greedy preg_replace('/.*wp-content/', ...) and no realpath or uploads containment, so a crafted old_files URL (a second wp-content segment) resolved outside wp-content/uploads and was unlinked. Route the delete sink through resolve_uploads_file_from_url(), which the accept path and the other callers in this class already use, and delete via wp_delete_file() so the file that is removed is the same validated, uploads-contained path. --- changelog.txt | 1 + includes/abstracts/class-evf-form-fields-upload.php | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/changelog.txt b/changelog.txt index c5dcbb23b..1a92eb215 100644 --- a/changelog.txt +++ b/changelog.txt @@ -4,6 +4,7 @@ * Fix - Improved security of smart tag processing for submitted form field values. * Fix - Improved security of permission checks for MCP-based form and entry access. * Fix - Improved security of admin notification message rendering. +* Fix - Improved security of file deletion when processing uploaded file submissions. = 3.6.1 - 07-09-2026 * Fix - Date/Time dropdown fields showed today's date instead of the saved value, and Smart Phone fields lost their country code, when editing an existing entry. diff --git a/includes/abstracts/class-evf-form-fields-upload.php b/includes/abstracts/class-evf-form-fields-upload.php index 977bf5346..9a0cc3620 100644 --- a/includes/abstracts/class-evf-form-fields-upload.php +++ b/includes/abstracts/class-evf-form-fields-upload.php @@ -1672,13 +1672,11 @@ public function remove_csv_file_after_email_send( $attachment, $entry, $form_dat if ( isset( $meta_value['type'] ) && ( 'file-upload' === $meta_value['type'] && ! empty( $meta_value['value_raw'] ) || 'image-upload' === $meta_value['type'] && isset( $meta_value['value_raw'] ) ) ) { foreach ( $meta_value['value_raw'] as $file_data ) { - if ( isset( $file_data['value'] ) ) { - $file_url = $file_data['value']; + $file_url = isset( $file_data['value'] ) && is_string( $file_data['value'] ) ? $file_data['value'] : ''; + $resolved_path = $this->resolve_uploads_file_from_url( $file_url ); - $uploaded_file = ABSPATH . preg_replace( '/.*wp-content/', 'wp-content', wp_parse_url( $file_url, PHP_URL_PATH ) ); - if ( file_exists( $uploaded_file ) ) { - unlink( $uploaded_file ); - } + if ( false !== $resolved_path ) { + wp_delete_file( $resolved_path ); } } } From e0def030c6a3631bb2db3d51629b72ce3811dc72 Mon Sep 17 00:00:00 2001 From: deepench Date: Mon, 12 Oct 2026 12:01:16 +0100 Subject: [PATCH 2/3] Fix - Confine upload cleanup deletion to the form's own uploads folder Containment to the uploads root still let an unauthenticated submitter delete any existing file under wp-content/uploads via a crafted old_files URL, because old_files is taken from the request and only checked for containment, not ownership. Restrict deletion to the form's own hash-protected everest_forms_uploads/- folder, which a request cannot forge, so a resolved path outside that folder is skipped. --- .../class-evf-form-fields-upload.php | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/includes/abstracts/class-evf-form-fields-upload.php b/includes/abstracts/class-evf-form-fields-upload.php index 9a0cc3620..db601297e 100644 --- a/includes/abstracts/class-evf-form-fields-upload.php +++ b/includes/abstracts/class-evf-form-fields-upload.php @@ -1660,6 +1660,8 @@ public function remove_csv_file_after_email_send( $attachment, $entry, $form_dat if ( isset( $form_data['settings']['disabled_entries'] ) && '1' === $form_data['settings']['disabled_entries'] ) { if ( ! empty( $entry ) && is_array( $entry ) ) { + $allowed_base = $this->get_form_uploads_base( $form_data ); + foreach ( $entry as $meta_key => $meta_value ) { if ( empty( $meta_value ) ) { continue; @@ -1675,7 +1677,8 @@ public function remove_csv_file_after_email_send( $attachment, $entry, $form_dat $file_url = isset( $file_data['value'] ) && is_string( $file_data['value'] ) ? $file_data['value'] : ''; $resolved_path = $this->resolve_uploads_file_from_url( $file_url ); - if ( false !== $resolved_path ) { + // Only delete files that live in this form's own hash-protected uploads folder, so a crafted old_files URL cannot remove another form's or user's file. + if ( false !== $resolved_path && '' !== $allowed_base && 0 === strpos( $resolved_path, $allowed_base ) ) { wp_delete_file( $resolved_path ); } } @@ -1822,6 +1825,30 @@ protected function resolve_uploads_file_from_url( $file_url ) { return $resolved_path; } + /** + * Resolve the real filesystem base of a form's own uploads directory. + * + * The folder name embeds a wp_hash() of the form id and creation date, so it + * is not forgeable from a request. Used to confine deletion to files that + * belong to the submitted form rather than the whole uploads directory. + * + * @since 3.6.3 + * + * @param array $form_data Form data. + * @return string Trailing-slashed real path, or '' when it cannot be derived. + */ + protected function get_form_uploads_base( $form_data ) { + if ( empty( $form_data['id'] ) || ! isset( $form_data['created'] ) ) { + return ''; + } + + $uploads = wp_get_upload_dir(); + $folder = absint( $form_data['id'] ) . '-' . wp_hash( $form_data['id'] . $form_data['created'] ); + $base = realpath( $uploads['basedir'] . '/everest_forms_uploads/' . $folder ); + + return $base ? wp_normalize_path( trailingslashit( $base ) ) : ''; + } + /** * Attach the csv file. * From 04daf9f79ee1873f08000f44ca929fff7361f597 Mon Sep 17 00:00:00 2001 From: deepench Date: Wed, 14 Oct 2026 07:59:21 +0100 Subject: [PATCH 3/3] Fix - Only delete files uploaded in the current request after email The cleanup still trusted file URLs from the entry, which old_files fills from the request, so an unauthenticated submission could delete any other existing file in the same form's uploads folder. Track the files this request moves into place and delete only those. This also drops the signature branch, which never matched here and threw a TypeError when a field ID contained "signature_", and adds the same is_string() guard to the attachment builder. Verified live (logged out, Save and Continue active, entries disabled): crafted old_files URLs for files in the same form, another form, outside the form folder and the wp-content/.. escape all survive; real file and image uploads are still attached to both notifications and then removed. --- .../class-evf-form-fields-upload.php | 69 +++++-------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/includes/abstracts/class-evf-form-fields-upload.php b/includes/abstracts/class-evf-form-fields-upload.php index db601297e..af396d872 100644 --- a/includes/abstracts/class-evf-form-fields-upload.php +++ b/includes/abstracts/class-evf-form-fields-upload.php @@ -30,6 +30,13 @@ abstract class EVF_Form_Fields_Upload extends EVF_Form_Fields { */ protected $protected_files = array( 'index.html', 'index.php', '.htaccess', '.htpasswd' ); + /** + * Real paths of files moved into the form uploads folder during this request. + * + * @var array + */ + protected $request_files = array(); + /** * Hook in tabs. */ @@ -1376,6 +1383,12 @@ static function ( $file ) { $this->create_dir( dirname( $file['path'] ) ); @rename( $file['tmp_path'], $file['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $this->set_file_fs_permissions( $file['path'] ); + + $real_path = realpath( $file['path'] ); + + if ( false !== $real_path ) { + $this->request_files[] = wp_normalize_path( $real_path ); + } } $data[] = $this->generate_file_data( $file ); @@ -1659,32 +1672,12 @@ public function send_csv_file_as_email_attachment( $attachment, $entry, $form_da public function remove_csv_file_after_email_send( $attachment, $entry, $form_data, $context, $connection_id, $entry_id ) { if ( isset( $form_data['settings']['disabled_entries'] ) && '1' === $form_data['settings']['disabled_entries'] ) { - if ( ! empty( $entry ) && is_array( $entry ) ) { - $allowed_base = $this->get_form_uploads_base( $form_data ); - - foreach ( $entry as $meta_key => $meta_value ) { - if ( empty( $meta_value ) ) { - continue; - } - - if ( preg_match( '/signature_/', $meta_key ) && file_exists( $meta_value ) ) { - unlink( $meta_value ); - } - - if ( isset( $meta_value['type'] ) && ( 'file-upload' === $meta_value['type'] && ! empty( $meta_value['value_raw'] ) || 'image-upload' === $meta_value['type'] && isset( $meta_value['value_raw'] ) ) ) { - - foreach ( $meta_value['value_raw'] as $file_data ) { - $file_url = isset( $file_data['value'] ) && is_string( $file_data['value'] ) ? $file_data['value'] : ''; - $resolved_path = $this->resolve_uploads_file_from_url( $file_url ); - - // Only delete files that live in this form's own hash-protected uploads folder, so a crafted old_files URL cannot remove another form's or user's file. - if ( false !== $resolved_path && '' !== $allowed_base && 0 === strpos( $resolved_path, $allowed_base ) ) { - wp_delete_file( $resolved_path ); - } - } - } - } + // File URLs in the entry can come from the request (old_files), so only delete what this request uploaded. + foreach ( $this->request_files as $file_path ) { + wp_delete_file( $file_path ); } + + $this->request_files = array(); } if ( ! $entry_id ) { @@ -1757,7 +1750,7 @@ public function attach_entry_files_upload( $entry ) { } if ( preg_match( '/signature_/', $meta_key ) ) { - if ( file_exists( $meta_value ) ) { + if ( is_string( $meta_value ) && file_exists( $meta_value ) ) { $entry_files[] = $meta_value; } } elseif ( isset( $meta_value['type'] ) && ( 'file-upload' === $meta_value['type'] && ! empty( $meta_value['value_raw'] ) || 'image-upload' === $meta_value['type'] && isset( $meta_value['value_raw'] ) ) ) { @@ -1825,30 +1818,6 @@ protected function resolve_uploads_file_from_url( $file_url ) { return $resolved_path; } - /** - * Resolve the real filesystem base of a form's own uploads directory. - * - * The folder name embeds a wp_hash() of the form id and creation date, so it - * is not forgeable from a request. Used to confine deletion to files that - * belong to the submitted form rather than the whole uploads directory. - * - * @since 3.6.3 - * - * @param array $form_data Form data. - * @return string Trailing-slashed real path, or '' when it cannot be derived. - */ - protected function get_form_uploads_base( $form_data ) { - if ( empty( $form_data['id'] ) || ! isset( $form_data['created'] ) ) { - return ''; - } - - $uploads = wp_get_upload_dir(); - $folder = absint( $form_data['id'] ) . '-' . wp_hash( $form_data['id'] . $form_data['created'] ); - $base = realpath( $uploads['basedir'] . '/everest_forms_uploads/' . $folder ); - - return $base ? wp_normalize_path( trailingslashit( $base ) ) : ''; - } - /** * Attach the csv file. *