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.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 ) ) {