Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/prosolution-wp-client/admin/class-prosolwpclient-admin.php
+++ b/prosolution-wp-client/admin/class-prosolwpclient-admin.php
@@ -312,7 +312,14 @@
* Ajax table sync
*/
public function proSol_ajaxTablesync() {
-
+
+ // WOEX-4657 (PS-014): the public portal shares the 'prosolwpclient' nonce, so a nonce
+ // check alone lets any logged-in user (e.g. Subscriber) trigger this admin-only sync.
+ // Authorize by capability first; never treat the public nonce as authorization.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( esc_html__( 'You are not allowed to run this action.', 'prosolution-wp-client' ), 403 );
+ }
+
//security check
check_ajax_referer( 'prosolwpclient', 'security' );
@@ -358,6 +365,11 @@
* Ajax clear log
*/
public function proSol_ajaxClearlog() {
+ // WOEX-4657 (PS-014): capability gate before the shared-nonce check (admin-only action).
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( esc_html__( 'You are not allowed to run this action.', 'prosolution-wp-client' ), 403 );
+ }
+
//security check
check_ajax_referer( 'prosolwpclient', 'security' );
@@ -391,10 +403,99 @@
return $prosolwpclient_header_info;
}
+ /**
+ * WOEX-4658 (PS-004): SSRF guard for proSol_url_validate().
+ *
+ * Only allow a plain http/https URL that points at a public host. Every IP
+ * the host resolves to is checked so DNS rebinding to an internal address is
+ * rejected too. Blocks loopback, RFC1918 private ranges, link-local and the
+ * cloud metadata endpoint (169.254.169.254). Returns true only for safe URLs.
+ */
+ public static function proSol_isSafeRemoteUrl( $url ) {
+ $url = trim( (string) $url );
+ if ( $url === '' ) {
+ return false;
+ }
+
+ $parts = wp_parse_url( $url );
+ if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
+ return false;
+ }
+
+ // Only http/https, and never accept URLs that embed credentials.
+ if ( ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
+ return false;
+ }
+ if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
+ return false;
+ }
+
+ $host = strtolower( trim( $parts['host'], '[]' ) ); // strip IPv6 brackets
+ $ips = self::proSol_resolveHostIps( $host );
+ if ( empty( $ips ) ) {
+ return false;
+ }
+
+ foreach ( $ips as $ip ) {
+ if ( ! self::proSol_isPublicIp( $ip ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /* used in proSol_isSafeRemoteUrl(): resolve a host to every IP it maps to */
+ private static function proSol_resolveHostIps( $host ) {
+ if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
+ return array( $host ); // already an IP literal
+ }
+
+ $ips = array();
+
+ $ipv4 = @gethostbynamel( $host );
+ if ( is_array( $ipv4 ) ) {
+ $ips = array_merge( $ips, $ipv4 );
+ }
+
+ if ( function_exists( 'dns_get_record' ) ) {
+ $ipv6 = @dns_get_record( $host, DNS_AAAA );
+ if ( is_array( $ipv6 ) ) {
+ foreach ( $ipv6 as $rec ) {
+ if ( ! empty( $rec['ipv6'] ) ) {
+ $ips[] = $rec['ipv6'];
+ }
+ }
+ }
+ }
+
+ return array_values( array_unique( $ips ) );
+ }
+
+ /* used in proSol_isSafeRemoteUrl(): true only for a routable public IP */
+ private static function proSol_isPublicIp( $ip ) {
+ // Rejects loopback, RFC1918 private and reserved (incl. link-local) ranges.
+ if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
+ return false;
+ }
+
+ // Belt-and-suspenders: explicitly block IPv4 link-local / cloud metadata.
+ if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && strpos( $ip, '169.254.' ) === 0 ) {
+ return false;
+ }
+
+ return true;
+ }
+
/**
*Ajax URL validate
*/
public function proSol_url_validate() {
+ // WOEX-4657 (PS-014): admin-only settings action; block non-privileged callers.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( esc_html__( 'You are not allowed to run this action.', 'prosolution-wp-client' ), 403 );
+ }
+
$prosolwpclient_response_data = new stdClass();
$urlval = isset( $_POST['urlval'] ) ? filter_var( $_POST['urlval'], FILTER_SANITIZE_STRING ) : '';
$userval = isset( $_POST['userval'] ) ? filter_var( $_POST['userval'], FILTER_SANITIZE_STRING ) : '';
@@ -405,8 +506,26 @@
$output['error'] = 1;
$output['message'] = esc_html__( 'URL is invalid', 'prosolution-wp-client' );
+
+ // WOEX-4658 (PS-004): SSRF guard - refuse to fetch internal / non-public URLs.
+ // Reject before any outbound request so loopback, private, link-local and
+ // cloud-metadata targets can never be reached through this admin action.
+ if ( $prosolwpclient_is_api_setup && ! CBXProSolWpClient_Admin::proSol_isSafeRemoteUrl( $urlval ) ) {
+ echo wp_json_encode( $output ); // error 1 / 'URL is invalid'
+ die();
+ }
+
if ( is_array( $prosolwpclient_header_info ) && sizeof( $prosolwpclient_header_info ) > 0 && $prosolwpclient_is_api_setup ) {
- $prosolwpclient_response_data = wp_remote_get( $urlval . '/go/api/system/list/maritalstatus', array('headers'=>$prosolwpclient_header_info));
+ $prosolwpclient_response_data = wp_remote_get(
+ $urlval . '/go/api/system/list/maritalstatus',
+ array(
+ 'headers' => $prosolwpclient_header_info,
+ 'timeout' => 15,
+ 'redirection' => 0,
+ 'reject_unsafe_urls' => true,
+ 'limit_response_size' => 1048576,
+ )
+ );
}
if ( $prosolwpclient_is_api_setup && !is_wp_error( $prosolwpclient_response_data ) ) {
--- a/prosolution-wp-client/includes/UploadHandler.php
+++ b/prosolution-wp-client/includes/UploadHandler.php
@@ -96,8 +96,9 @@
'readfile_chunk_size' => 10 * 1024 * 1024, // 10 MiB
// Defines which files can be displayed inline when downloaded:
'inline_file_types' => '/.(gif|jpe?g|png)$/i',
- // Defines which files (based on their names) are accepted for upload:
- 'accept_file_types' => '/.+$/i',
+ // Defines which files (based on their names) are accepted for upload.
+ // Must stay aligned with proSol_fileUploadProcess whitelist (PS-008 / WOEX-4655).
+ 'accept_file_types' => '/.(gif|jpe?g|png|webp|pdf|docx?)$/i',
// The php.ini settings upload_max_filesize and post_max_size
// take precedence over the following max_file_size setting:
'max_file_size' => null,
@@ -1323,14 +1324,6 @@
return $this->proSol_delete($print_response);
}
$upload = $this->proSol_getUploadData($this->options['param_name']);
- // Parse the Content-Disposition header, if available:
- $content_disposition_header = $this->proSol_getServerVar('HTTP_CONTENT_DISPOSITION');
- $file_name = $content_disposition_header ?
- rawurldecode(preg_replace(
- '/(^[^"]+")|("$)/',
- '',
- $content_disposition_header
- )) : null;
// Parse the Content-Range header, which has the following form:
// Content-Range: bytes 0-524287/2000000
$content_range_header = $this->proSol_getServerVar('HTTP_CONTENT_RANGE');
@@ -1341,11 +1334,14 @@
if ($upload) {
if (is_array($upload['tmp_name'])) {
// param_name is an array identifier like "files[]",
- // $upload is a multi-dimensional array:
+ // $upload is a multi-dimensional array.
+ // PS-008 / WOEX-4655: always use multipart part names from $_FILES.
+ // Never prefer HTTP_CONTENT_DISPOSITION — attackers can set a safe
+ // multipart filename for validation while forcing a .php on-disk name.
foreach ($upload['tmp_name'] as $prosolwpclient_index => $prosolwpclient_value) {
$files[] = $this->proSol_handleFileUpload(
$upload['tmp_name'][$prosolwpclient_index],
- $file_name ? $file_name : $upload['name'][$prosolwpclient_index],
+ $upload['name'][$prosolwpclient_index],
$size ? $size : $upload['size'][$prosolwpclient_index],
$upload['type'][$prosolwpclient_index],
$upload['error'][$prosolwpclient_index],
@@ -1358,8 +1354,7 @@
// $upload is a one-dimensional array:
$files[] = $this->proSol_handleFileUpload(
isset($upload['tmp_name']) ? $upload['tmp_name'] : null,
- $file_name ? $file_name : (isset($upload['name']) ?
- $upload['name'] : null),
+ isset($upload['name']) ? $upload['name'] : null,
$size ? $size : (isset($upload['size']) ?
$upload['size'] : $this->proSol_getServerVar('CONTENT_LENGTH')),
isset($upload['type']) ?
@@ -1369,6 +1364,25 @@
$content_range
);
}
+ } else {
+ // Non-multipart (PUT) uploads only: Content-Disposition may be the sole name source.
+ // Still subject to accept_file_types before anything is written.
+ $content_disposition_header = $this->proSol_getServerVar('HTTP_CONTENT_DISPOSITION');
+ $file_name = $content_disposition_header ?
+ rawurldecode(preg_replace(
+ '/(^[^"]+")|("$)/',
+ '',
+ $content_disposition_header
+ )) : null;
+ $files[] = $this->proSol_handleFileUpload(
+ null,
+ $file_name,
+ $size ? $size : $this->proSol_getServerVar('CONTENT_LENGTH'),
+ $this->proSol_getServerVar('CONTENT_TYPE'),
+ null,
+ null,
+ $content_range
+ );
}
$prosolwpclient_response = array($this->options['param_name'] => $files);
return $this->proSol_generateResponse($prosolwpclient_response, $print_response);
--- a/prosolution-wp-client/includes/class-prosolwpclient-table-helper.php
+++ b/prosolution-wp-client/includes/class-prosolwpclient-table-helper.php
@@ -220,21 +220,62 @@
return $prosolwpclient_response_data;
}
- public static function proSol_cleardatasites() {
- $remsite='';
-
- if(isset($_COOKIE['removesite'])){
- $remsite=$_COOKIE['removesite'];
- $tablename_arr = CBXProSolWpClient_Helper::proSol_allTablesArr();
- global $wpdb;global $prosolwpclient_prefix;
- foreach($tablename_arr as $tablename => $label){
- $ps_table_name = $prosolwpclient_prefix.$tablename;
- $wpdb->query( "DELETE FROM $ps_table_name WHERE site_id in ($remsite) " );
+ /**
+ * Apply additional-site removal to plugin tables: delete removed site_id rows,
+ * then remap surviving site_id values to the new contiguous indexes.
+ *
+ * @param int[] $deleted_ids Removed site indexes (positive ints).
+ * @param array $id_map Map of old_site_id => new_site_id for survivors.
+ */
+ public static function proSol_applySiteRemovalToTables( array $deleted_ids, array $id_map ) {
+ $deleted_ids = array_values( array_filter( array_map( 'intval', $deleted_ids ), static function( $id ) {
+ return $id > 0;
+ } ) );
+
+ $tablename_arr = CBXProSolWpClient_Helper::proSol_allTablesArr();
+ global $wpdb;
+ global $prosolwpclient_prefix;
+
+ if ( ! empty( $deleted_ids ) ) {
+ $placeholders = implode( ',', array_fill( 0, count( $deleted_ids ), '%d' ) );
+ foreach ( $tablename_arr as $tablename => $label ) {
+ $ps_table_name = $prosolwpclient_prefix . $tablename;
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM `{$ps_table_name}` WHERE site_id IN ($placeholders)",
+ $deleted_ids
+ )
+ );
}
}
+ // Remap in ascending new-index order so the target id is empty (deleted or already moved).
+ asort( $id_map );
+ foreach ( $id_map as $old_id => $new_id ) {
+ $old_id = intval( $old_id );
+ $new_id = intval( $new_id );
+ if ( $old_id <= 0 || $new_id <= 0 || $old_id === $new_id ) {
+ continue;
+ }
+ foreach ( $tablename_arr as $tablename => $label ) {
+ $ps_table_name = $prosolwpclient_prefix . $tablename;
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE `{$ps_table_name}` SET site_id = %d WHERE site_id = %d",
+ $new_id,
+ $old_id
+ )
+ );
+ }
+ }
+ }
- return $remsite;
+ /**
+ * Legacy entry point kept for compatibility. Prefer proSol_applySiteRemovalToTables()
+ * via the settings site-removal processor (WOEX-4656).
+ */
+ public static function proSol_cleardatasites() {
+ return '';
}
// project 1440, set custom interval
--- a/prosolution-wp-client/includes/class-setting.php
+++ b/prosolution-wp-client/includes/class-setting.php
@@ -110,6 +110,11 @@
//delete_option('site0_prosolwpclient_privacypolicy'); //for remove option manually, uncomment when create new
//delete_option('prosolwpclient_designtemplate');
//register settings sections
+
+ // WOEX-4656: process additional-site removal BEFORE reading/writing section
+ // options. Must remap all siteN_* keys (not only registered fields) and must
+ // not rely on the api_config oldapi_pass special-save path.
+ $this->proSol_processSiteRemoval();
foreach ( $this->settings_sections as $section ) {
if ( false == get_option( $section['id'] ) ) {
@@ -153,7 +158,7 @@
}
add_settings_section( $section['id'], $section['title'], $callback, $section['id'] );
}
-
+
//register settings fields
foreach ( $this->settings_fields as $section => $prosolwpclient_field ) {
foreach ( $prosolwpclient_field as $option ) {
@@ -228,49 +233,12 @@
$section_value = get_option( $section_id );
$fields = $this->settings_fields[ $section_id ];
-
- //transfer old data to new index site
- if( isset($_COOKIE['removesite']) && get_option('prosolwpclient_additionalsite')['chkremove']==1){
- $deletedsite_arr= explode(',' , $_COOKIE['removesite']);
- $prosolwpclient_totalsite=intval(get_option('prosolwpclient_additionalsite')['valids']);
-
- //assign new value based on new index for tab prosolwpclient_additionalsite
- if($section_id=='prosolwpclient_additionalsite'){
- for($prosolwpclient_x=1;$prosolwpclient_x<=$prosolwpclient_totalsite;$prosolwpclient_x++){
- $new_index = $this->proSol_getNewIndexAfterSiteRemoval( $prosolwpclient_x );
- $section_value['addsite'.$prosolwpclient_x] = get_option($section_id)['addsite'.$new_index];
- $section_value['addsite'.$prosolwpclient_x.'_urlid'] = get_option($section_id)['addsite'.$new_index.'_urlid'];
- }
- }
-
- foreach ( $fields as $prosolwpclient_field ) {
-
- //assign new value based on new index for setting site only
- $chksite=substr($prosolwpclient_field['name'],0,4);
- if($chksite=='site'){
- $pos = strpos($prosolwpclient_field['name'], '_');
- $onlyfieldname=substr($prosolwpclient_field['name'],$pos,strlen($prosolwpclient_field['name']));
- $chksitekey= substr($prosolwpclient_field['name'],4,$pos-4);
- $new_index = $this->proSol_getNewIndexAfterSiteRemoval( $chksitekey );
- $section_value[ $prosolwpclient_field['name'] ] = get_option($section_id)['site'.$new_index.$onlyfieldname];
- // if($section_id=='prosolwpclient_designtemplate'){
- // var_dump($chksitekey);
- // var_dump(get_option('prosolwpclient_languages'));
- // }
-
- }
- if ( ! isset( $section_value[ $prosolwpclient_field['name'] ] ) ) {
- $section_value[ $prosolwpclient_field['name'] ] = isset( $prosolwpclient_field['default'] ) ? $prosolwpclient_field['default'] : '';
- }
- }
-
- } else{
-
- foreach ( $fields as $prosolwpclient_field ) {
- if ( ! isset( $section_value[ $prosolwpclient_field['name'] ] ) ) {
- $section_value[ $prosolwpclient_field['name'] ] = isset( $prosolwpclient_field['default'] ) ? $prosolwpclient_field['default'] : '';
- }
+ // Site-removal remapping is handled once by proSol_processSiteRemoval()
+ // before this runs. Here we only fill missing field defaults.
+ foreach ( $fields as $prosolwpclient_field ) {
+ if ( ! isset( $section_value[ $prosolwpclient_field['name'] ] ) ) {
+ $section_value[ $prosolwpclient_field['name'] ] = isset( $prosolwpclient_field['default'] ) ? $prosolwpclient_field['default'] : '';
}
}
@@ -278,35 +246,167 @@
}
/**
- * Prepares new index site
+ * Process additional-site removal: remap all per-site option keys and plugin
+ * table site_id values so survivors become contiguous (e.g. delete site1 →
+ * old site2 becomes site1 with its own config preserved).
*
- * @param $rev_index
+ * Fixes pre-existing bugs where:
+ * - api_config remapped values were never saved (oldapi_pass special path)
+ * - only registered settings fields were remapped (orphaned keys kept old values)
+ * - DB rows for survivors were not remapped after delete
*
- * @return string
+ * WOEX-4656 / PS-037 / PS-038
*/
- function proSol_getNewIndexAfterSiteRemoval( $rev_index ) {
- $new_index = $rev_index;
- if( isset($_COOKIE['removesite']) && get_option('prosolwpclient_additionalsite')['chkremove']==1){
- $new_index_arr= [];
- $deletedsite_arr =[];
- array_push($new_index_arr,'0');
- $deletedsite_arr= explode(',' , $_COOKIE['removesite']);
- $prosolwpclient_totalsite=intval(get_option('prosolwpclient_additionalsite')['valids']);
- //insert new order
- $totaloldindex=count($deletedsite_arr)+$prosolwpclient_totalsite;
- for($prosolwpclient_x=1;$prosolwpclient_x<=$totaloldindex;$prosolwpclient_x++){
- if( !in_array($prosolwpclient_x,$deletedsite_arr) ){
- array_push($new_index_arr,$prosolwpclient_x);
- }
- }
- //get new index
- $new_index=$new_index_arr[$rev_index];
-
- return $new_index;
- } else{
- return $new_index;
+ private function proSol_processSiteRemoval() {
+ static $already_ran = false;
+ if ( $already_ran ) {
+ return;
+ }
+ $already_ran = true;
+
+ if ( ! isset( $_COOKIE['removesite'] ) ) {
+ return;
+ }
+ if ( ! function_exists( 'current_user_can' ) || ! function_exists( 'wp_get_current_user' ) ) {
+ return;
+ }
+ if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ $additionalsite = get_option( 'prosolwpclient_additionalsite' );
+ if ( ! is_array( $additionalsite )
+ || empty( $additionalsite['chkremove'] )
+ || intval( $additionalsite['chkremove'] ) !== 1 ) {
+ return;
+ }
+
+ $deleted_ids = array_values( array_unique( array_filter(
+ array_map( 'intval', explode( ',', wp_unslash( $_COOKIE['removesite'] ) ) ),
+ static function( $id ) {
+ return $id > 0;
+ }
+ ) ) );
+
+ if ( empty( $deleted_ids ) ) {
+ $additionalsite['chkremove'] = '0';
+ update_option( 'prosolwpclient_additionalsite', $additionalsite );
+ $this->proSol_clearRemovesiteCookie();
+ return;
+ }
+
+ $valids_after = intval( isset( $additionalsite['valids'] ) ? $additionalsite['valids'] : 0 );
+ $id_map = $this->proSol_buildSiteIdMap( $deleted_ids, $valids_after );
+
+ // 1) Tables: delete removed sites, remap survivors.
+ CBXProSolWpClient_TableHelper::proSol_applySiteRemovalToTables( $deleted_ids, $id_map );
+
+ // 2) Options: remap every array option group that may hold siteN_* keys.
+ $option_ids = array(
+ 'prosolwpclient_additionalsite',
+ 'prosolwpclient_api_config',
+ 'prosolwpclient_applicationform',
+ 'prosolwpclient_designtemplate',
+ 'prosolwpclient_frontend',
+ 'prosolwpclient_languages',
+ 'prosolwpclient_privacypolicy',
+ 'prosolwpclient_tools',
+ 'prosolwpclient_joblist',
+ );
+ foreach ( $this->settings_sections as $section ) {
+ if ( ! empty( $section['id'] ) ) {
+ $option_ids[] = $section['id'];
+ }
+ }
+ $option_ids = array_values( array_unique( $option_ids ) );
+
+ foreach ( $option_ids as $option_id ) {
+ $option_value = get_option( $option_id );
+ if ( ! is_array( $option_value ) ) {
+ // Scalars such as encryptionkey / vectorkey are site-agnostic.
+ continue;
+ }
+ $remapped = $this->proSol_remapOptionSiteKeys( $option_value, $id_map, $deleted_ids );
+ if ( $option_id === 'prosolwpclient_additionalsite' ) {
+ $remapped['valids'] = (string) $valids_after;
+ $remapped['chkremove'] = '0';
+ }
+ update_option( $option_id, $remapped );
}
+ $this->proSol_clearRemovesiteCookie();
+ }
+
+ /**
+ * Build old_site_id => new_site_id map for survivors after a removal.
+ *
+ * @param int[] $deleted_ids
+ * @param int $valids_after Number of additional sites remaining after save.
+ * @return array
+ */
+ private function proSol_buildSiteIdMap( array $deleted_ids, $valids_after ) {
+ $deleted_lookup = array_fill_keys( $deleted_ids, true );
+ $old_max = count( $deleted_ids ) + intval( $valids_after );
+ $map = array();
+ $new_id = 1;
+ for ( $old_id = 1; $old_id <= $old_max; $old_id++ ) {
+ if ( isset( $deleted_lookup[ $old_id ] ) ) {
+ continue;
+ }
+ $map[ $old_id ] = $new_id;
+ $new_id++;
+ }
+ return $map;
+ }
+
+ /**
+ * Remap siteN_* / addsiteN keys; drop deleted indexes; keep master keys.
+ *
+ * @param array $option_value
+ * @param array $id_map old => new
+ * @param int[] $deleted_ids
+ * @return array
+ */
+ private function proSol_remapOptionSiteKeys( array $option_value, array $id_map, array $deleted_ids ) {
+ $deleted_lookup = array_fill_keys( $deleted_ids, true );
+ $result = array();
+
+ foreach ( $option_value as $key => $value ) {
+ if ( preg_match( '/^site(d+)_(.+)$/', $key, $m ) ) {
+ $old_id = intval( $m[1] );
+ if ( isset( $deleted_lookup[ $old_id ] ) || ! isset( $id_map[ $old_id ] ) ) {
+ continue;
+ }
+ $result[ 'site' . $id_map[ $old_id ] . '_' . $m[2] ] = $value;
+ continue;
+ }
+
+ if ( preg_match( '/^addsite(d+)(_urlid)?$/', $key, $m ) ) {
+ $old_id = intval( $m[1] );
+ if ( isset( $deleted_lookup[ $old_id ] ) || ! isset( $id_map[ $old_id ] ) ) {
+ continue;
+ }
+ $suffix = isset( $m[2] ) ? $m[2] : '';
+ $result[ 'addsite' . $id_map[ $old_id ] . $suffix ] = $value;
+ continue;
+ }
+
+ $result[ $key ] = $value;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Expire the removesite cookie so removal does not re-run on the next request.
+ */
+ private function proSol_clearRemovesiteCookie() {
+ unset( $_COOKIE['removesite'] );
+ if ( ! headers_sent() ) {
+ $path = defined( 'COOKIEPATH' ) ? COOKIEPATH : '/';
+ $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
+ setcookie( 'removesite', '', time() - YEAR_IN_SECONDS, $path, $domain );
+ }
}
/**
--- a/prosolution-wp-client/prosolwpclient.php
+++ b/prosolution-wp-client/prosolwpclient.php
@@ -16,7 +16,7 @@
* Plugin Name: ProSolution WP Client
* Plugin URI: https://prosolution.com/produkte-und-services/workexpert.html
* Description: WordPress client for ProSolution
- * Version: 2.0.8
+ * Version: 2.0.9
* Author: ProSolution
* Author URI: https://www.prosolution.com
* License: GPL-2.0+
@@ -44,7 +44,7 @@
defined('PROSOLWPCLIENT_PLUGIN_NAME') or define('PROSOLWPCLIENT_PLUGIN_NAME', 'prosolwpclient');
- defined('PROSOLWPCLIENT_PLUGIN_VERSION') or define('PROSOLWPCLIENT_PLUGIN_VERSION', '2.0.8');
+ defined('PROSOLWPCLIENT_PLUGIN_VERSION') or define('PROSOLWPCLIENT_PLUGIN_VERSION', '2.0.9');
defined('PROSOLWPCLIENT_BASE_NAME') or define('PROSOLWPCLIENT_BASE_NAME', plugin_basename(__FILE__));
defined('PROSOLWPCLIENT_ROOT_PATH') or define('PROSOLWPCLIENT_ROOT_PATH', plugin_dir_path(__FILE__));
defined('PROSOLWPCLIENT_ROOT_URL') or define('PROSOLWPCLIENT_ROOT_URL', plugin_dir_url(__FILE__));
@@ -385,8 +385,9 @@
$plugin = new CBXProSolWpClient();
$plugin->proSol_runs();
- //remove deleted site's data
- CBXProSolWpClient_TableHelper::proSol_cleardatasites();
+ // Site-removal cleanup (options remap + table delete/reindex) runs from
+ // CBXProSolWpClient_Settings_API::proSol_processSiteRemoval() on admin_init
+ // after the current user is available (WOEX-4656).
}
// Setting a custom timeout value for cURL. Using a high value for priority to ensure the function runs after any other added to the same action hook.
--- a/prosolution-wp-client/public/class-prosolwpclient-public.php
+++ b/prosolution-wp-client/public/class-prosolwpclient-public.php
@@ -1114,6 +1114,11 @@
}
}
+ // Defense in depth (PS-008 / WOEX-4655): deny PHP execution under the upload dir (Apache).
+ if ( $folder_exists && is_dir( $prosol_base_dir ) ) {
+ $this->proSol_ensureUploadDirHardening( $prosol_base_dir );
+ }
+
return array(
'folder_exists' => $folder_exists,
'upload_dir_basedir' => $upload_dir_basedir,
@@ -1124,6 +1129,143 @@
}
/**
+ * Write .htaccess (and empty index) so scripts cannot execute from the upload folder.
+ *
+ * @param string $prosol_base_dir Absolute path with trailing slash.
+ */
+ private function proSol_ensureUploadDirHardening( $prosol_base_dir ) {
+ $htaccess = $prosol_base_dir . '.htaccess';
+ if ( ! file_exists( $htaccess ) ) {
+ $rules = "# ProSolution WP Client — block script execution (WOEX-4655 / PS-008)n"
+ . "<IfModule mod_authz_core.c>n"
+ . " Require all grantedn"
+ . "</IfModule>n"
+ . "<FilesMatch "(?i)\.(php|phtml|php[0-9]|phar|cgi|pl|py|asp|aspx|jsp|shtml)$">n"
+ . " <IfModule mod_authz_core.c>n"
+ . " Require all deniedn"
+ . " </IfModule>n"
+ . " <IfModule !mod_authz_core.c>n"
+ . " Order allow,denyn"
+ . " Deny from alln"
+ . " </IfModule>n"
+ . "</FilesMatch>n"
+ . "Options -ExecCGI -Indexesn"
+ . "RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .pharn"
+ . "RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .pharn";
+ @file_put_contents( $htaccess, $rules );
+ }
+
+ $index = $prosol_base_dir . 'index.html';
+ if ( ! file_exists( $index ) ) {
+ @file_put_contents( $index, '' );
+ }
+ }
+
+ /**
+ * Absolute uploads/prosolwpclient/ directory with trailing slash (normalized).
+ *
+ * @return string
+ */
+ private function proSol_getUploadBaseDir() {
+ $upload_dir = wp_upload_dir();
+ return trailingslashit( wp_normalize_path( $upload_dir['basedir'] . '/prosolwpclient' ) );
+ }
+
+ /**
+ * Reduce a user-supplied upload name to a single safe basename (no traversal).
+ * Rejects empty values, path separators, and ".." components (PS-009 / CVE-2026-14524).
+ *
+ * @param string $filename Raw filename from request or session.
+ * @return string Safe basename, or empty string if rejected.
+ */
+ private function proSol_sanitizeUploadBasename( $filename ) {
+ $filename = is_string( $filename ) ? $filename : '';
+ $filename = trim( $filename );
+ if ( $filename === '' ) {
+ return '';
+ }
+
+ // Reject traversal / directory components before basename reduction.
+ if ( strpos( $filename, '..' ) !== false
+ || strpos( $filename, '/' ) !== false
+ || strpos( $filename, '\' ) !== false
+ || strpos( $filename, "