Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions includes/class-forms.php
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ public function process_image_crop( $data = array(), $type = 'avatar', $unlink_p
*/
public function normalize_url( $url ) {

if ( empty( $url ) ) {
return '';
}
// Normalize.
$url = wp_normalize_path( $url );

Expand Down
147 changes: 141 additions & 6 deletions includes/class-import-export.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,44 @@ class UsersWP_Import_Export {
private $file;
private $filename;

/**
* Columns that are NEVER writable via CSV import.
* Denylist wins over allowlist — a column here can never be accidentally
* re-enabled by adding it to $import_meta_allowlist.
*
* @var array
*/
private static $import_meta_denylist = array(
'user_id', // Primary key — never importable.
'old_password', // Credential-adjacent — must not be set via import.
);

/**
* Columns that ARE permitted in a CSV import (positive / allowlist).
* Everything not listed here is silently skipped — default-deny.
* Add new safe meta keys here deliberately; never use a wildcard.
*
* @var array
*/
private static $import_meta_allowlist = array(
// Core WP user fields
'username',
'email',
'display_name',
'first_name',
'last_name',
'description',
'user_url',
'user_registered',
'role',
// uwp_usermeta safe fields
'bio',
'phone',
'user_privacy',
'avatar_thumb',
'banner_thumb',
);


public function __construct() {
global $wp_filesystem;
Expand All @@ -44,7 +82,6 @@ public function __construct() {
add_action( 'wp_ajax_uwp_ajax_export_users', array( $this, 'process_users_export' ) );
add_action( 'wp_ajax_uwp_ajax_import_users', array( $this, 'process_users_import' ) );
add_action( 'wp_ajax_uwp_ie_upload_file', array( $this, 'ie_upload_file' ) );
add_action( 'wp_ajax_nopriv_uwp_ie_upload_file', array( $this, 'ie_upload_file' ) );
add_action( 'admin_notices', array($this, 'ie_admin_notice') );
add_filter( 'uwp_get_export_users_status', array( $this, 'get_export_users_status' ) );
add_filter( 'uwp_get_import_users_status', array( $this, 'get_import_users_status' ) );
Expand Down Expand Up @@ -458,10 +495,12 @@ public function get_export_users_status() {
*/
public function ie_upload_file(){

if ( !(!empty($_REQUEST['nonce']) && wp_verify_nonce( $_REQUEST['nonce'], 'uwp-ie-file-upload-nonce' )) ) {
echo 'error';return;
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'userswp' ) ), 403 );
}

check_ajax_referer( 'uwp-ie-file-upload-nonce', 'nonce' );

$upload_data = array(
'name' => $_FILES['import_file']['name'],
'type' => $_FILES['import_file']['type'],
Expand Down Expand Up @@ -670,10 +709,13 @@ public function process_import_step() {

if( !is_wp_error( $user_id ) ){
foreach ($row as $key => $value){
if(!in_array($key, $exclude)){
$value = maybe_unserialize($value);
uwp_update_usermeta($user_id, $key, $value);
//Only write columns on the allowlist; denylist always wins.
if ( ! $this->is_importable_column( $key ) ) {
continue;
}
//Never deserialize CSV input. Cast to safe scalar string.
$value = $this->sanitize_import_value( $key, $value );
uwp_update_usermeta($user_id, $key, $value);
}
} else {
$return['msg'] = sprintf(__('Row - %s Error: %s','userswp'), $this->imp_step, $user_id->get_error_message());
Expand Down Expand Up @@ -802,6 +844,99 @@ public function allowed_upload_mimes($mimes = array()) {
return $mimes;
}

/**
* Sanitize a single CSV import value.
*
* CSV data is always a plain string. There is no legitimate reason for it
* to contain serialized PHP. We detect the serialization type-prefix
* signatures, reject them with a log entry, and return an empty string.
* All other values are cast to string and sanitized with sanitize_text_field().
*
* @param string $key The CSV column / meta key name.
* @param mixed $value The raw value from the CSV row.
* @return string A safe scalar string ready for DB insertion.
*/
private function sanitize_import_value( $key, $value ) {
// Reject serialized payloads
if ( is_string( $value ) && preg_match( '/^[aAbBdDiIoOsScCnN][:;]/', ltrim( $value ) ) ) {
if ( function_exists( 'uwp_log' ) ) {
uwp_log( sprintf( 'Import security: serialized payload in column "%s" — discarded.', esc_attr( $key ) ) );
}
return '';
}

// File path columns: validate as a URL pointing inside the uploads directory only
if ( in_array( $key, array( 'avatar_thumb', 'banner_thumb' ), true ) ) {
return $this->sanitize_import_thumb( $value );
}

return sanitize_text_field( (string) $value );
}

/**
* Sanitizes a thumbnail path value from CSV import.
*
* Validates that the given path is a real, existing file located within
* the WordPress uploads directory, preventing path traversal attacks and
* references to arbitrary files outside the uploads directory.
*
* @param string $value Raw thumbnail path value from the CSV row.
* @return string Resolved absolute path if valid, empty string otherwise.
*/
private function sanitize_import_thumb( $value ) {
$value = trim( (string) $value );

if ( empty( $value ) ) {
return '';
}

// Resolve any ../ traversal attempts before comparison
$real = realpath( $value );

if ( $real === false ) {
return ''; // Path doesn't exist on disk — reject
}

// Must stay within the uploads directory
$uploads = wp_upload_dir();
$base_dir = trailingslashit( realpath( $uploads['basedir'] ) );

if ( strpos( $real . DIRECTORY_SEPARATOR, $base_dir ) !== 0 ) {
if ( function_exists( 'uwp_log' ) ) {
uwp_log( sprintf(
'Import security: thumb path "%s" is outside uploads directory — discarded.',
esc_attr( $value )
) );
}
return '';
}

// Must be an allowed image extension
$ext = strtolower( pathinfo( $real, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ), true ) ) {
return '';
}

return $real; // Return the resolved, canonical path
}

/**
* Return true only when the given column name is permitted for CSV import.
*
* @param string $column The CSV column / meta key name.
* @return bool
*/
private function is_importable_column( $column ) {
$column = strtolower( trim( (string) $column ) );

// Denylist is checked first — it unconditionally blocks.
if ( in_array( $column, self::$import_meta_denylist, true ) ) {
return false;
}

return in_array( $column, self::$import_meta_allowlist, true );
}

/**
* Escape a string to be used in a CSV export.
*
Expand Down
2 changes: 1 addition & 1 deletion includes/helpers/misc.php
Original file line number Diff line number Diff line change
Expand Up @@ -1881,7 +1881,7 @@ function uwp_get_activation_link($user_id)
$activation_args = array(
'uwp_activate' => 'yes',
'key' => $key,
'login' => $user_data->user_login
'login' => rawurlencode($user_data->user_login)
);

$activation_args = apply_filters('uwp_activation_link_args', $activation_args, $user_id, $user_data);
Expand Down
4 changes: 4 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ Yes, you can customize it with Elementor, but also with Gutenberg, Divi, Beaver

== Changelog ==

= 1.2.58 - 2026-03-TBD =
* UsersWP import workflow CSV import security improvement - FIXED/SECURITY
* Invalid activation link for usernames that contain spaces or special characters - FIXED

= 1.2.57 - 2026-03-10 =
* Re-release to resolve a deployment failure caused by GitHub service outage - FIXED

Expand Down