Published : August 13, 2026

CVE-2026-13612: KiviCare – Clinic & Patient Management System (EHR) < 4.5.2 Authenticated (Custom role+) Information Exposure PoC, Patch Analysis & Rule

Severity Medium (CVSS 4.3)
CWE 200
Vulnerable Version 4.5.2
Patched Version 4.5.2
Disclosed August 12, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-13612: This vulnerability allows authenticated attackers with custom role-level access and above to extract sensitive user or configuration data from the KiviCare – Clinic & Patient Management System (EHR) plugin for WordPress, versions up to 4.5.2. The flaw stems from insufficient authorization checks across multiple REST API endpoints and AJAX handlers, enabling unauthorized access to patient, billing, and payment gateway data. Atomic Edge research rates this as medium severity (CVSS 4.3) due to the requirement of an authenticated account, but the potential for data exposure makes it a significant privacy risk.

The root cause lies in several controllers that fail to enforce proper object-level access control. In AppointmentsController, the permission callback `checkUserPermission` allowed any user with the ‘appointment’ capability to view any appointment, including those belonging to other clinics or providers. BillController’s `checkViewPermission` similarly checked only the ‘patient_bill’ view capability without verifying that the requested bill or encounter belonged to the user. The `ConfigController` endpoint that returned `payment_methods` called the vulnerable `getPaymentMethodsArray()` method, which exposed full gateway settings including secret keys and API URLs. Additionally, `StaticDataController` allowed unauthenticated access to endpoint that could retrieve patient lists, and the `AuthController` registration checks were overly permissive, allowing registration of staff roles without proper module or role settings validation.

Exploitation involves sending authenticated REST API requests to the affected endpoints. For appointment data, an attacker with a custom role can call `/wp-json/kivicare/v1/appointment/view` (or the appropriate route) with an `id` parameter pointing to another user’s appointment. For billing data, the attacker calls the bill endpoint with an `id` or `encounter_id` (e.g., `/wp-json/kivicare/v1/bill/details` with `id=1`). These requests succeed because the permission callbacks only verify a generic capability, not ownership. For payment gateway secrets, the attacker requests the config endpoint (e.g., `/wp-json/kivicare/v1/config/get`) and receives the full `payment_methods` array, including `secretKey` values. The StaticDataController endpoint may also be accessible without authentication if the request includes `dataType=patientList`.

The patch introduces object-level access checks in `AppointmentsController::canAccessAppointmentId()` and `BillController::canAccessEncounterId()`, validating that the appointment or encounter belongs to the current user or is within their clinic. The `ConfigController` now uses `KCPaymentGatewayFactory::get_payment_methods_config()`, which calls `get_public_config()` on each gateway to return only safe values such as the payment method ID, stripping out secret keys and internal URLs. `StaticDataController` adds a permission callback `checkStaticDataPermission` that restricts patient list requests to authenticated users. `AuthController` adds validation for staff role registration, requiring the role to be enabled and the receptionist module to be active.

Successful exploitation allows an authenticated attacker to view medical appointment details, patient encounter data, and billing information, potentially including sensitive health information. Critically, the attacker can retrieve payment gateway secret keys and API URLs, which could enable further attacks such as payment fraud or credential theft. This represents a direct breach of patient confidentiality and can lead to regulatory non-compliance.

Differential between vulnerable and patched code

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

Code Diff
--- a/kivicare-clinic-management-system/app/abstracts/KCAbstractPaymentGateway.php
+++ b/kivicare-clinic-management-system/app/abstracts/KCAbstractPaymentGateway.php
@@ -140,6 +140,30 @@
     public function get_settings(){
         return $this->settings;
     }
+
+    /**
+     * Public/mobile-safe gateway configuration.
+     *
+     * Gateway implementations may override this to expose SDK-safe values such
+     * as public keys, environment, and client-side API URLs. Secret credentials
+     * must never be returned from this method.
+     *
+     * @return array
+     */
+    public function get_public_config(): array
+    {
+        return ['paymentMethod' => $this->get_public_payment_method_id()];
+    }
+
+    /**
+     * Client-facing payment method identifier.
+     *
+     * @return string
+     */
+    protected function get_public_payment_method_id(): string
+    {
+        return (string) $this->gateway_id;
+    }

     /**
      * Log gateway activity
--- a/kivicare-clinic-management-system/app/admin/KCDashboardPermalinkHandler.php
+++ b/kivicare-clinic-management-system/app/admin/KCDashboardPermalinkHandler.php
@@ -109,6 +109,10 @@
             return $redirect;
         }

+        if ($role === 'administrator') {
+            return $redirect;
+        }
+
         $login_redirects = KCOption::get('login_redirect', []);

         if (!empty($login_redirects[$role])) {
@@ -525,7 +529,12 @@
      */
     public function redirect_to_user_dashboard()
     {
-        $user_role = KCBase::get_instance()->KCGetRoles();
+        $user_role = KCBase::get_instance()->getLoginUserRole();
+        if ($user_role === 'administrator') {
+            wp_safe_redirect(admin_url());
+            exit;
+        }
+
         $dashboard_url = $this->get_dashboard_url($user_role);
         if ($dashboard_url) {
             wp_safe_redirect($dashboard_url);
--- a/kivicare-clinic-management-system/app/baseClasses/KCApp.php
+++ b/kivicare-clinic-management-system/app/baseClasses/KCApp.php
@@ -144,6 +144,10 @@
             return $redirect_to;
         }

+        if ($role === 'administrator') {
+            return $redirect_to;
+        }
+
         apply_filters('kc_login_redirect_role', $role, $user, KCDashboardPermalinkHandler::instance()->get_dashboard_url($role));
         // Check if a custom redirect is set for this role
         if (!empty($login_redirects[$role])) {
--- a/kivicare-clinic-management-system/app/baseClasses/KCPaymentGatewayFactory.php
+++ b/kivicare-clinic-management-system/app/baseClasses/KCPaymentGatewayFactory.php
@@ -153,6 +153,35 @@
     }

     /**
+     * Get enabled payment method configuration safe for frontend/mobile clients.
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    public static function get_payment_methods_config(): array
+    {
+        $methods = [];
+
+        foreach (self::get_available_gateways(false) as $gateway_data) {
+            $gateway = $gateway_data['instance'] ?? null;
+
+            if (!$gateway instanceof KCAbstractPaymentGateway || !$gateway->is_enabled()) {
+                continue;
+            }
+
+            $gateway_methods = $gateway->get_public_config();
+
+            if (!isset($gateway_methods['paymentMethod'])) {
+                $methods = array_merge($methods, array_values($gateway_methods));
+                continue;
+            }
+
+            $methods[] = $gateway_methods;
+        }
+
+        return apply_filters('kc_payment_methods_config', $methods);
+    }
+
+    /**
      * Get a gateway instance by ID.
      *
      * @param string $gateway_id      Gateway identifier
@@ -187,4 +216,4 @@

         return $gateway;
     }
-}
 No newline at end of file
+}
--- a/kivicare-clinic-management-system/app/controllers/api/AppointmentsController.php
+++ b/kivicare-clinic-management-system/app/controllers/api/AppointmentsController.php
@@ -20,6 +20,7 @@
 use AppmodelsKCPaymentsAppointmentMapping;
 use AppmodelsKCUserMeta;
 use AppmodelsKCUser;
+use AppmodelsKCReceptionistClinicMapping;
 use AppmodelsKCPatientMedicalReport;
 use AppservicesKCAppointmentDataService;
 use AppservicesKCAppointmentPaymentService;
@@ -1311,9 +1312,13 @@
                     return $this->checkResourceAccess('appointment', 'view');
                 }

+                if (strpos($route, '/print-report/') !== false) {
+                    return $this->checkResourceAccess('appointment', 'view');
+                }
+
                 // For single appointment view
                 if (isset($request['id'])) {
-                    return $this->checkResourceAccess('appointment', 'view');
+                    return $this->checkResourceAccess('appointment', 'view') && $this->canAccessAppointmentId((int) $request['id']);
                 }

                 // For appointment list - check appointment_list capability
@@ -1344,6 +1349,57 @@
         }
     }

+    private function canAccessAppointmentId(int $appointmentId): bool
+    {
+        if ($appointmentId <= 0) {
+            return false;
+        }
+
+        $appointment = KCAppointment::find($appointmentId);
+        if (!$appointment) {
+            return false;
+        }
+
+        $role = $this->kcbase->getLoginUserRole();
+        $currentUserId = get_current_user_id();
+
+        if ($role === 'administrator') {
+            return true;
+        }
+
+        if ($role === $this->kcbase->getPatientRole()) {
+            return (int) $appointment->patientId === (int) $currentUserId;
+        }
+
+        if ($role === $this->kcbase->getDoctorRole()) {
+            return (int) $appointment->doctorId === (int) $currentUserId;
+        }
+
+        if ($role === $this->kcbase->getReceptionistRole()) {
+            return in_array((int) $appointment->clinicId, $this->getReceptionistClinicIds($currentUserId), true);
+        }
+
+        if ($role === $this->kcbase->getClinicAdminRole()) {
+            $clinic = KCClinic::find((int) $appointment->clinicId);
+            return $clinic && (int) $clinic->clinicAdminId === (int) $currentUserId;
+        }
+
+        return false;
+    }
+
+    /**
+     * @return int[]
+     */
+    private function getReceptionistClinicIds(int $receptionistId): array
+    {
+        return KCReceptionistClinicMapping::query()
+            ->where('receptionistId', $receptionistId)
+            ->select(['clinic_id'])
+            ->get()
+            ->map(fn($row) => (int) $row->clinicId)
+            ->toArray();
+    }
+
     /**
      * Check if user has permission to create an appointment
      *
--- a/kivicare-clinic-management-system/app/controllers/api/AuthController.php
+++ b/kivicare-clinic-management-system/app/controllers/api/AuthController.php
@@ -601,21 +601,22 @@
      */
     public function checkRegistrationPermission($request) :bool|WP_Error
     {
-        KCErrorLogger::instance()->error("AuthController: checkRegistrationPermission called");
-
-        // Allow registration if WordPress registration is enabled OR if KiviCare registration is specifically enabled
-        if (get_option('users_can_register')) {
-            KCErrorLogger::instance()->error("AuthController: WordPress registration enabled");
-            return true;
-        }
-
-        // Determine user role
         $user_role = $request->get_param('user_role') ?? $this->kcbase->getPatientRole();
-        KCErrorLogger::instance()->error("Requested user role: " . $user_role);

-        // Validate if the selected role is enabled in settings
+        return $this->validatePublicRegistrationRole($user_role);
+    }
+
+    private function validatePublicRegistrationRole(string $user_role): bool|WP_Error
+    {
         $role_settings = KCOption::get('user_registration_shortcode_role_setting') ?? [];
-        if (isset($role_settings[$user_role]) && $role_settings[$user_role] !== 'on') {
+        $role_enabled = (($role_settings[$user_role] ?? 'off') === 'on');
+
+        $staff_roles = [
+            $this->kcbase->getDoctorRole(),
+            $this->kcbase->getReceptionistRole(),
+        ];
+
+        if (in_array($user_role, $staff_roles, true) && !$role_enabled) {
             return new WP_Error(
                 'role_not_allowed',
                 __('Selected user role is not enabled for registration', 'kivicare-clinic-management-system'),
@@ -623,32 +624,39 @@
             );
         }

-        // Block receptionist registration if the receptionist module is disabled
-        $receptionist_role = $this->kcbase->getReceptionistRole();
-        if ($user_role === $receptionist_role || $user_role === 'receptionist') {
-            $modules_data  = kcGetModules();
-            $module_config = $modules_data['module_config'] ?? [];
-            $receptionist_on = false;
-            foreach ($module_config as $mod) {
-                if (($mod['name'] ?? '') === 'receptionist') {
-                    $receptionist_on = ($mod['status'] === '1' || $mod['status'] === 1 || $mod['status'] === true);
-                    break;
-                }
-            }
-            if (!$receptionist_on) {
-                return new WP_Error(
-                    'role_not_allowed',
-                    __('Receptionist registration is currently disabled.', 'kivicare-clinic-management-system'),
-                    ['status' => 403]
-                );
-            }
+        if ($user_role === $this->kcbase->getPatientRole() && !get_option('users_can_register') && !$role_enabled) {
+            return new WP_Error(
+                'role_not_allowed',
+                __('Selected user role is not enabled for registration', 'kivicare-clinic-management-system'),
+                ['status' => 400]
+            );
+        }
+
+        if ($user_role === $this->kcbase->getReceptionistRole() && !$this->isReceptionistModuleEnabled()) {
+            return new WP_Error(
+                'role_not_allowed',
+                __('Receptionist registration is currently disabled.', 'kivicare-clinic-management-system'),
+                ['status' => 403]
+            );
         }

-        // Default to allowing registration for KiviCare (since this is a medical system)
-        KCErrorLogger::instance()->error("AuthController: Allowing registration by default");
         return true;
     }

+    private function isReceptionistModuleEnabled(): bool
+    {
+        $modules_data = kcGetModules();
+        $module_config = $modules_data['module_config'] ?? [];
+
+        foreach ($module_config as $module) {
+            if (($module['name'] ?? '') === 'receptionist') {
+                return in_array($module['status'] ?? null, ['1', 1, true], true);
+            }
+        }
+
+        return false;
+    }
+
     public function checkLogoutPermission($request)
     {
         return is_user_logged_in();
@@ -1277,14 +1285,14 @@
         // Determine user role
         $user_role = $params['user_role'] ?? $this->kcbase->getPatientRole();

-        // Validate if the selected role is enabled in settings
-        $role_settings = KCOption::get('user_registration_shortcode_role_setting') ?? [];
-        if (isset($role_settings[$user_role]) && $role_settings[$user_role] !== 'on') {
+        $role_validation = $this->validatePublicRegistrationRole($user_role);
+        if (is_wp_error($role_validation)) {
+            $error_data = $role_validation->get_error_data();
             return $this->response(
                 null,
-                __('Selected user role is not enabled for registration', 'kivicare-clinic-management-system'),
+                $role_validation->get_error_message(),
                 false,
-                400
+                $error_data['status'] ?? 400
             );
         }

@@ -1977,4 +1985,4 @@
         }
     }

-}
 No newline at end of file
+}
--- a/kivicare-clinic-management-system/app/controllers/api/BillController.php
+++ b/kivicare-clinic-management-system/app/controllers/api/BillController.php
@@ -15,6 +15,7 @@
 use AppmodelsKCPaymentsAppointmentMapping;
 use AppmodelsKCServiceDoctorMapping;
 use AppmodelsKCAppointmentServiceMapping;
+use AppmodelsKCReceptionistClinicMapping;
 use WP_REST_Request;
 use WP_REST_Response;
 use KCProAppcontrollersapiGoogleCalendarIntegration;
@@ -210,13 +211,95 @@
         ];
     }

-    public function checkViewPermission()
+    public function checkViewPermission($request = null)
     {
         if (!$this->isModuleEnabled('billing')) {
             return false;
         }
-        // Check if user has permission to list
-        return $this->checkResourceAccess('patient_bill', 'view');
+
+        if (!$this->checkResourceAccess('patient_bill', 'view')) {
+            return false;
+        }
+
+        if ($request instanceof WP_REST_Request) {
+            if ($request->get_param('id')) {
+                return $this->canAccessBillId((int) $request->get_param('id'));
+            }
+
+            if ($request->get_param('encounter_id')) {
+                return $this->canAccessEncounterId((int) $request->get_param('encounter_id'));
+            }
+        }
+
+        return true;
+    }
+
+    private function canAccessBillId(int $billId): bool
+    {
+        if ($billId <= 0) {
+            return false;
+        }
+
+        $bill = KCBill::find($billId);
+        return $bill && $this->canAccessEncounterId((int) $bill->encounterId);
+    }
+
+    private function canAccessEncounterId(int $encounterId): bool
+    {
+        if ($encounterId <= 0) {
+            return false;
+        }
+
+        $encounter = KCPatientEncounter::find($encounterId);
+        if (!$encounter) {
+            return false;
+        }
+
+        $role = $this->kcbase->getLoginUserRole();
+        $currentUserId = get_current_user_id();
+        $clinicId = (int) $encounter->clinicId;
+
+        return match ($role) {
+            'administrator' => true,
+            $this->kcbase->getPatientRole() => (int) $encounter->patientId === (int) $currentUserId,
+            $this->kcbase->getDoctorRole() => (int) $encounter->doctorId === (int) $currentUserId,
+            $this->kcbase->getReceptionistRole() => $this->receptionistCanAccessClinic($currentUserId, $clinicId),
+            $this->kcbase->getClinicAdminRole() => $this->clinicAdminCanAccessClinic($currentUserId, $clinicId),
+            default => false,
+        };
+    }
+
+    private function receptionistCanAccessClinic(int $receptionistId, int $clinicId): bool
+    {
+        if ($receptionistId <= 0 || $clinicId <= 0) {
+            return false;
+        }
+
+        return in_array($clinicId, $this->getReceptionistClinicIds($receptionistId), true);
+    }
+
+    private function clinicAdminCanAccessClinic(int $clinicAdminId, int $clinicId): bool
+    {
+        if ($clinicAdminId <= 0 || $clinicId <= 0) {
+            return false;
+        }
+
+        $clinic = KCClinic::find($clinicId);
+
+        return $clinic && (int) $clinic->clinicAdminId === (int) $clinicAdminId;
+    }
+
+    /**
+     * @return int[]
+     */
+    private function getReceptionistClinicIds(int $receptionistId): array
+    {
+        return KCReceptionistClinicMapping::query()
+            ->where('receptionist_id', $receptionistId)
+            ->select(['clinic_id'])
+            ->get()
+            ->map(fn($row) => (int) $row->clinicId)
+            ->toArray();
     }

     public function checkPermission($request)
--- a/kivicare-clinic-management-system/app/controllers/api/ConfigController.php
+++ b/kivicare-clinic-management-system/app/controllers/api/ConfigController.php
@@ -2,9 +2,11 @@

 namespace Appcontrollersapi;

+use AppbaseClassesKCActivate;
 use AppbaseClassesKCBase;
 use AppbaseClassesKCBaseController;
 use AppbaseClassesKCPaymentGatewayFactory;
+use AppbaseClassesKCPermissions;
 use AppmodelsKCClinic;
 use AppmodelsKCOption;
 use KCProAppcontrollersapiKCProPermissionSetting;
@@ -218,7 +220,7 @@

         // Lazy generation: activation hook may have been missed on existing installs
         if (!$public_key) {
-            AppbaseClassesKCActivate::generate_server_key_pair();
+            KCActivate::generate_server_key_pair();
             $public_key = get_option('kc_server_public_key');
         }

@@ -492,7 +494,7 @@
             /**
              * Payment methods with detailed configuration.
              */
-            $response['payment_methods'] = $this->getPaymentMethodsArray();
+            $response['payment_methods'] = KCPaymentGatewayFactory::get_payment_methods_config();

             // Define the sub-module configurations to process
             $response['encounter_modules'] = apply_filters('kcpro_get_encounter_list',[]);
@@ -506,7 +508,7 @@
             if ($user_id && is_user_logged_in()) {
                 $userObj = new WP_User($user_id);
                 $final_capabilities = $userObj->allcaps;
-                $user_role_key = AppbaseClassesKCPermissions::get_user_role($user_id);
+                $user_role_key = KCPermissions::get_user_role($user_id);

                 if (function_exists('isKiviCareProActive') && isKiviCareProActive()) {
                     $lookup_key = ($user_role_key === 'admin') ? 'administrator' : str_replace(KIVI_CARE_PREFIX, '', $user_role_key);
@@ -1039,145 +1041,6 @@
         }
     }

-    /**
-     * Get payment methods array with detailed configuration
-     * Only returns enabled payment methods
-     *
-     * @return array
-     */
-    private function getPaymentMethodsArray(): array
-    {
-        $payment_methods = [];
-        $gateways = AppbaseClassesKCPaymentGatewayFactory::get_available_gateways();
-
-        // Map gateway IDs to user's expected format
-        $gateway_id_mapping = [
-            'razorpay' => 'razorPay',
-            'stripe' => 'stripe',
-            'stripepay' => 'stripe',
-            'woocommerce' => 'wooCommerce',
-            'manual' => 'offline',
-            'paypal' => 'paypal'
-        ];
-
-        foreach ($gateways as $gateway_id => $gateway_data) {
-            $instance = $gateway_data['instance'] ?? null;
-
-            if (!$instance) {
-                continue;
-            }
-
-            // Only include enabled payment methods
-            $is_enabled = $instance->is_enabled() ?? false;
-            if (!$is_enabled) {
-                continue;
-            }
-
-            $mapped_id = $gateway_id_mapping[$gateway_id] ?? $gateway_id;
-            // Use get_settings() method to properly retrieve settings
-            $settings = method_exists($instance, 'get_settings') ? $instance->get_settings() : ($instance->settings ?? []);
-
-            // Build payment method object
-            $payment_method = [
-                'paymentMethod' => $mapped_id,
-            ];
-
-            // For offline/manual payment, only include paymentMethod
-            if ($gateway_id === 'manual') {
-                unset($payment_method['paymentURL']); // Remove URL for offline to match desired output
-                $payment_methods[] = $payment_method;
-                continue;
-            }
-            // Same for woocommerce? User JSON had "paymentMethod": "wooCommerce" and nothing else.
-            if ($gateway_id === 'woocommerce') {
-                // $checkout_page_id = get_option('woocommerce_checkout_page_id');
-                // $payment_method['paymentURL'] = $checkout_page_id ? get_permalink($checkout_page_id) : site_url();
-                $payment_methods[] = $payment_method;
-                continue;
-            }
-
-            // For gateways that support environment/mode
-            $environment = 'live';
-            $secret_key = '';
-            $public_key = '';
-            $payment_url = '';
-
-            switch ($gateway_id) {
-                case 'razorpay':
-                    // Razorpay - get settings from instance first, then fallback to option
-                    $razorpay_settings = $settings;
-
-                    // If settings are empty or missing credentials, try getting directly from option
-                    if (empty($razorpay_settings) || empty($razorpay_settings['key_id']) || empty($razorpay_settings['key_secret'])) {
-                        $option_settings = get_option(KIVI_CARE_PREFIX . 'razorpay_setting', []);
-                        if (is_string($option_settings)) {
-                            $option_settings = json_decode($option_settings, true) ?? [];
-                        }
-                        // Merge option settings with instance settings (option takes precedence)
-                        if (!empty($option_settings)) {
-                            $razorpay_settings = array_merge($razorpay_settings, $option_settings);
-                        }
-                    }
-
-                    $mode = $razorpay_settings['mode'] ?? 'sandbox';
-                    $environment = ($mode === 'sandbox') ? 'test' : 'live';
-                    $secret_key = $razorpay_settings['key_secret'] ?? '';
-                    $public_key = $razorpay_settings['key_id'] ?? '';
-                    $payment_url = 'https://api.razorpay.com/v1';
-                    break;
-
-                case 'paypal':
-                    // PayPal uses 'mode' with id 0 (sandbox) or 1 (live)
-                    $mode = $settings['mode'] ?? [];
-                    if (is_array($mode) && isset($mode['id'])) {
-                        $environment = ($mode['id'] == '0' || $mode['id'] === 0) ? 'test' : 'live';
-                    } else {
-                        $environment = ($mode == '0' || $mode === 0) ? 'test' : 'live';
-                    }
-                    $secret_key = $settings['client_secret'] ?? '';
-                    $public_key = $settings['client_id'] ?? '';
-                    $payment_url = ($environment === 'test')
-                        ? 'https://api.sandbox.paypal.com'
-                        : 'https://api.paypal.com';
-                    break;
-
-                case 'stripe':
-                case 'stripepay':
-                    // Stripe - get settings from instance or directly from options
-                    $stripe_settings = $settings;
-
-                    // Fallback to option if empty
-                    if (empty($stripe_settings) || (empty($stripe_settings['api_key']) && empty($stripe_settings['publishable_key']))) {
-                         $option_key = defined('KIVI_CARE_PREFIX') ? KIVI_CARE_PREFIX . 'stripepay_setting' : 'kiviCare_stripepay_setting';
-                         $option_settings = get_option($option_key, []);
-                         if (is_string($option_settings)) {
-                            $option_settings = json_decode($option_settings, true) ?? [];
-                         }
-                         if (!empty($option_settings)) {
-                             $stripe_settings = array_merge($stripe_settings, $option_settings);
-                         }
-                    }
-
-                    $mode = $stripe_settings['mode'] ?? 'sandbox';
-                    $environment = ($mode === 'sandbox' || $mode === 'test') ? 'test' : 'live';
-                    // Stripe addon uses 'api_key' for secret, 'publishable_key' for public
-                    $secret_key = $stripe_settings['api_key'] ?? $stripe_settings['secret_key'] ?? $stripe_settings['secretKey'] ?? '';
-                    $public_key = $stripe_settings['publishable_key'] ?? $stripe_settings['publishableKey'] ?? $stripe_settings['public_key'] ?? '';
-                    $payment_url = 'https://api.stripe.com/v1';
-                    break;
-            }
-
-            // Populate fields - always include structure if it's one of the processed gateways
-            $payment_method['environment'] = $environment;
-            $payment_method['secretKey'] = $secret_key;
-            $payment_method['publicKey'] = $public_key;
-            $payment_method['paymentURL'] = $payment_url;
-
-            $payment_methods[] = $payment_method;
-        }
-
-        return $payment_methods;
-    }

     /**
      * Get general configuration
--- a/kivicare-clinic-management-system/app/controllers/api/KCPrintInvoiceController.php
+++ b/kivicare-clinic-management-system/app/controllers/api/KCPrintInvoiceController.php
@@ -11,6 +11,7 @@
 use AppmodelsKCPaymentsAppointmentMapping;
 use AppmodelsKCPatientEncounter;
 use AppmodelsKCBill;
+use AppmodelsKCReceptionistClinicMapping;
 use WP_REST_Request;
 use WP_REST_Response;
 use ApputilsKCPdfGenerator;
@@ -37,6 +38,73 @@
         ]);
     }

+    public function checkPermission($request)
+    {
+        if (!$this->checkCapability('read') || !$this->checkResourceAccess('appointment', 'view')) {
+            return false;
+        }
+
+        return $this->canAccessAppointmentId((int) $request->get_param('id'));
+    }
+
+    private function canAccessAppointmentId(int $appointmentId): bool
+    {
+        if ($appointmentId <= 0) {
+            return false;
+        }
+
+        $appointment = KCAppointment::find($appointmentId);
+        if (!$appointment) {
+            return false;
+        }
+
+        $role = $this->kcbase->getLoginUserRole();
+        $currentUserId = get_current_user_id();
+        $clinicId = (int) $appointment->clinicId;
+
+        return match ($role) {
+            'administrator' => true,
+            $this->kcbase->getPatientRole() => (int) $appointment->patientId === (int) $currentUserId,
+            $this->kcbase->getDoctorRole() => (int) $appointment->doctorId === (int) $currentUserId,
+            $this->kcbase->getReceptionistRole() => $this->receptionistCanAccessClinic($currentUserId, $clinicId),
+            $this->kcbase->getClinicAdminRole() => $this->clinicAdminCanAccessClinic($currentUserId, $clinicId),
+            default => false,
+        };
+    }
+
+    private function receptionistCanAccessClinic(int $receptionistId, int $clinicId): bool
+    {
+        if ($receptionistId <= 0 || $clinicId <= 0) {
+            return false;
+        }
+
+        return in_array($clinicId, $this->getReceptionistClinicIds($receptionistId), true);
+    }
+
+    private function clinicAdminCanAccessClinic(int $clinicAdminId, int $clinicId): bool
+    {
+        if ($clinicAdminId <= 0 || $clinicId <= 0) {
+            return false;
+        }
+
+        $clinic = KCClinic::find($clinicId);
+
+        return $clinic && (int) $clinic->clinicAdminId === (int) $clinicAdminId;
+    }
+
+    /**
+     * @return int[]
+     */
+    private function getReceptionistClinicIds(int $receptionistId): array
+    {
+        return KCReceptionistClinicMapping::query()
+            ->where('receptionist_id', $receptionistId)
+            ->select(['clinic_id'])
+            ->get()
+            ->map(fn($row) => (int) $row->clinicId)
+            ->toArray();
+    }
+
     public function print(WP_REST_Request $request)
     {
         try {
@@ -242,4 +310,4 @@

         return KIVI_CARE_DIR . 'templates/KCInvoicePrintTemplate.php';
     }
-}
 No newline at end of file
+}
--- a/kivicare-clinic-management-system/app/controllers/api/SettingsController/ListingData.php
+++ b/kivicare-clinic-management-system/app/controllers/api/SettingsController/ListingData.php
@@ -264,11 +264,11 @@
             $query->where(function ($q) use ($searchTerm, $like) {
                 if (is_numeric($searchTerm)) {
                     $q->where('id', $searchTerm)
-                      ->orWhereRaw("LOWER(type) LIKE '{$like}'")
-                      ->orWhereRaw("LOWER(value) LIKE '{$like}'");
+                      ->orWhereRaw('LOWER(type) LIKE %s', [$like])
+                      ->orWhereRaw('LOWER(value) LIKE %s', [$like]);
                 } else {
-                    $q->whereRaw("LOWER(type) LIKE '{$like}'")
-                      ->orWhereRaw("LOWER(value) LIKE '{$like}'");
+                    $q->whereRaw('LOWER(type) LIKE %s', [$like])
+                      ->orWhereRaw('LOWER(value) LIKE %s', [$like]);
                 }
             });
         }
@@ -453,11 +453,11 @@
                 $query->where(function ($q) use ($searchTerm, $like) {
                     if (is_numeric($searchTerm)) {
                         $q->where('id', $searchTerm)
-                          ->orWhereRaw("LOWER(type) LIKE '{$like}'")
-                          ->orWhereRaw("LOWER(value) LIKE '{$like}'");
+                          ->orWhereRaw('LOWER(type) LIKE %s', [$like])
+                          ->orWhereRaw('LOWER(value) LIKE %s', [$like]);
                     } else {
-                        $q->whereRaw("LOWER(type) LIKE '{$like}'")
-                          ->orWhereRaw("LOWER(value) LIKE '{$like}'");
+                        $q->whereRaw('LOWER(type) LIKE %s', [$like])
+                          ->orWhereRaw('LOWER(value) LIKE %s', [$like]);
                     }
                 });
             }
--- a/kivicare-clinic-management-system/app/controllers/api/StaticDataController.php
+++ b/kivicare-clinic-management-system/app/controllers/api/StaticDataController.php
@@ -200,13 +200,30 @@
                     'description' => __('Whether the request is from the mobile app (allows inactive clinics in some cases)', 'kivicare-clinic-management-system'),
                 ],
             ],
-            'permission_callback' => function (WP_REST_Request $request) {
-                // For now, allowing all requests - can be customized later
-                return true;
-            },
+            'permission_callback' => [$this, 'checkStaticDataPermission'],
         ]);
     }

+    public function checkStaticDataPermission(WP_REST_Request $request)
+    {
+        $isPatientListRequest = $request->get_param('dataType') === 'patientList'
+            || ($request->get_param('dataType') === 'staticData' && $request->get_param('staticDataType') === 'patients');
+
+        if (!$isPatientListRequest) {
+            return true;
+        }
+
+        if (!is_user_logged_in() || !$this->checkCapability('read')) {
+            return false;
+        }
+
+        if ($this->kcbase->getLoginUserRole() === $this->kcbase->getPatientRole()) {
+            return false;
+        }
+
+        return $this->checkResourceAccess('patient', 'view');
+    }
+
     public function getStaticData(WP_REST_Request $request): WP_REST_Response
     {
         try {
@@ -645,7 +662,7 @@
     /**
      * Get clinic list
      */
-    private function getClinicList(?WP_REST_Request $request = null): array
+    private function getClinicList(WP_REST_Request $request): array
     {
         try {
             $currentUserRole = $this->kcbase->getLoginUserRole();
@@ -710,8 +727,22 @@
                  $clinicsQuery->where('status', 1);
             }

+            $clinicId = $request->get_param('clinic_id');
+            $clinicIds = $request->get_param('clinic_ids');
+
+            if (!empty($clinicId)) {
+                $clinicsQuery->where('id', absint($clinicId));
+            } elseif (!empty($clinicIds)) {
+                $clinicIdsArray = is_array($clinicIds) ? $clinicIds : explode(',', (string) $clinicIds);
+                $clinicIdsArray = array_filter(array_map('absint', $clinicIdsArray));
+
+                if (!empty($clinicIdsArray)) {
+                    $clinicsQuery->whereIn('id', $clinicIdsArray);
+                }
+            }
+
             // Handle search
-            if ($request instanceof WP_REST_Request && !empty($request->get_param('search'))) {
+            if (!empty($request->get_param('search'))) {
                 $search = $request->get_param('search');
                 if ($search !== 'undefined' && $search !== 'null') {
                     $clinicsQuery->where('name', 'LIKE', '%' . $search . '%');
@@ -719,72 +750,68 @@
             }

             // Filter by service_id
-            if ($request instanceof WP_REST_Request) {
-                $serviceId = $request->get_param('service_id');
-                if (!empty($serviceId)) {
-                    $serviceClinicIds = KCServiceDoctorMapping::query()
-                        ->where('service_id', absint($serviceId))
-                        ->where('status', 1)
-                        ->select(['clinic_id'])
-                        ->groupBy('clinic_id')
-                        ->get()
-                        ->map(fn($row) => $row->clinicId)
-                        ->toArray();
+            $serviceId = $request->get_param('service_id');
+            if (!empty($serviceId)) {
+                $serviceClinicIds = KCServiceDoctorMapping::query()
+                    ->where('service_id', absint($serviceId))
+                    ->where('status', 1)
+                    ->select(['clinic_id'])
+                    ->groupBy('clinic_id')
+                    ->get()
+                    ->map(fn($row) => $row->clinicId)
+                    ->toArray();

-                    $serviceClinicIds = array_filter($serviceClinicIds);
+                $serviceClinicIds = array_filter($serviceClinicIds);

-                    if (!empty($serviceClinicIds)) {
-                        $clinicsQuery->whereIn('id', $serviceClinicIds);
-                    } else {
-                        // Service not found in any clinic
-                        $clinicsQuery->whereRaw('0=1');
-                    }
+                if (!empty($serviceClinicIds)) {
+                    $clinicsQuery->whereIn('id', $serviceClinicIds);
+                } else {
+                    // Service not found in any clinic
+                    $clinicsQuery->whereRaw('0=1');
                 }
             }

             // Filter by doctor_id and exclude clinics with existing sessions
-            if ($request instanceof WP_REST_Request) {
-                $doctorId = $request->get_param('doctor_id');
-                $excludeWithSessions = $request->get_param('exclude_with_sessions');
-
-                if (!empty($doctorId)) {
-
-                    // First, filter to only clinics where this doctor works
-                    $doctorClinicIds = KCDoctorClinicMapping::query()
-                        ->where('doctor_id', absint($doctorId))
-                        ->select(['clinic_id'])
-                        ->get()
-                        ->map(fn($row) => $row->clinicId)
-                        ->toArray();
-
-                    $doctorClinicIds = array_filter($doctorClinicIds);
-
+            $doctorId = $request->get_param('doctor_id');
+            $excludeWithSessions = $request->get_param('exclude_with_sessions');

-                    if (!empty($doctorClinicIds)) {
-                        $clinicsQuery->whereIn('id', $doctorClinicIds);
+            if (!empty($doctorId)) {

-                        // If exclude_with_sessions is true, exclude clinics where doctor has sessions
-                        if ($excludeWithSessions === true) {
-                            // Get clinic IDs where this doctor already has sessions
-                            $sessionClinicIds = AppmodelsKCClinicSession::query()
-                                ->where('doctorId', absint($doctorId))
-                                ->select(['clinic_id'])
-                                ->groupBy('clinic_id')
-                                ->get()
-                                ->map(fn($row) => $row->clinicId)
-                                ->toArray();
-
-                            $sessionClinicIds = array_filter($sessionClinicIds);
-
-                            if (!empty($sessionClinicIds)) {
-                                // Exclude clinics where doctor already has sessions
-                                $clinicsQuery->whereNotIn('id', $sessionClinicIds);
-                            }
+                // First, filter to only clinics where this doctor works
+                $doctorClinicIds = KCDoctorClinicMapping::query()
+                    ->where('doctor_id', absint($doctorId))
+                    ->select(['clinic_id'])
+                    ->get()
+                    ->map(fn($row) => $row->clinicId)
+                    ->toArray();
+
+                $doctorClinicIds = array_filter($doctorClinicIds);
+
+
+                if (!empty($doctorClinicIds)) {
+                    $clinicsQuery->whereIn('id', $doctorClinicIds);
+
+                    // If exclude_with_sessions is true, exclude clinics where doctor has sessions
+                    if ($excludeWithSessions === true) {
+                        // Get clinic IDs where this doctor already has sessions
+                        $sessionClinicIds = AppmodelsKCClinicSession::query()
+                            ->where('doctorId', absint($doctorId))
+                            ->select(['clinic_id'])
+                            ->groupBy('clinic_id')
+                            ->get()
+                            ->map(fn($row) => $row->clinicId)
+                            ->toArray();
+
+                        $sessionClinicIds = array_filter($sessionClinicIds);
+
+                        if (!empty($sessionClinicIds)) {
+                            // Exclude clinics where doctor already has sessions
+                            $clinicsQuery->whereNotIn('id', $sessionClinicIds);
                         }
-                    } else {
-                        // Doctor not associated with any clinic
-                        $clinicsQuery->whereRaw('0=1');
                     }
+                } else {
+                    // Doctor not associated with any clinic
+                    $clinicsQuery->whereRaw('0=1');
                 }
             }

@@ -792,13 +819,11 @@
             $totalCount = $clinicsQuery->count();

             // Handle pagination
-            if ($request instanceof WP_REST_Request) {
-                $page = $request->get_param('page') ?: 1;
-                $perPage = $request->get_param('per_page') ?: 10;
-                $offset = ($page - 1) * $perPage;
-                if ($page !== -1) {
-                    $clinicsQuery->limit($perPage)->offset($offset);
-                }
+            $page = $request->get_param('page') ?: 1;
+            $perPage = $request->get_param('per_page') ?: 10;
+            $offset = ($page - 1) * $perPage;
+            if ($page !== -1) {
+                $clinicsQuery->limit($perPage)->offset($offset);
             }
             $clinics = $clinicsQuery->get();

@@ -842,30 +867,20 @@
                 ];
             })->toArray();

-            // Add pagination metadata if request provided
-            if ($request instanceof WP_REST_Request) {
-                $page = $request->get_param('page') ?: 1;
-                $perPage = $request->get_param('per_page') ?: 10;
-                $totalPages = ceil($totalCount / $perPage);
-
-                return [
-                    'data' => $result,
-                    'pagination' => [
-                        'total' => $totalCount,
-                        'per_page' => $perPage,
-                        'current_page' => $page,
-                        'total_pages' => $totalPages,
-                        'has_more' => $page < $totalPages,
-                    ]
-                ];
-            }
+            $totalPages = ceil($totalCount / $perPage);

-            return $result;
+            return [
+                'data' => $result,
+                'pagination' => [
+                    'total' => $totalCount,
+                    'per_page' => $perPage,
+                    'current_page' => $page,
+                    'total_pages' => $totalPages,
+                    'has_more' => $page < $totalPages,
+                ]
+            ];
         } catch (Exception $e) {
-            if ($request instanceof WP_REST_Request) {
-                return ['data' => [], 'pagination' => ['total' => 0, 'per_page' => 10, 'current_page' => 1, 'total_pages' => 0, 'has_more' => false]];
-            }
-            return [];
+            return ['data' => [], 'pagination' => ['total' => 0, 'per_page' => 10, 'current_page' => 1, 'total_pages' => 0, 'has_more' => false]];
         }
     }

--- a/kivicare-clinic-management-system/app/models/KCBill.php
+++ b/kivicare-clinic-management-system/app/models/KCBill.php
@@ -176,11 +176,13 @@
         $prefix = $currency_format['prefix'] ?? '';
         $postfix = $currency_format['postfix'] ?? '';

+        $total_revenue = is_object($total) ? ($total->total_revenue ?? 0) : (is_numeric($total) ? $total : 0);
+
         // Format the total with number_format for proper thousand separators
-        $formatted_total = $prefix . number_format($total->total_revenue) . $postfix;
+        $formatted_total = $prefix . number_format((float) $total_revenue) . $postfix;
         return [
-            'count' => $total->total_revenue ?? 0,
+            'count' => $total_revenue,
             'formatted_count' => $formatted_total
         ];
     }
-}
 No newline at end of file
+}
--- a/kivicare-clinic-management-system/app/paymentGateways/KCKnitPay.php
+++ b/kivicare-clinic-management-system/app/paymentGateways/KCKnitPay.php
@@ -145,6 +145,31 @@
     }

     /**
+     * Public/mobile-safe Knit Pay payment methods.
+     *
+     * Knit Pay can expose multiple configured payment methods, so this returns
+     * a list of payment method configs instead of a single gateway config.
+     *
+     * @return array<int, array<string, string>>
+     */
+    public function get_public_config(): array
+    {
+        $methods = [];
+
+        foreach ($this->get_enabled_configs() as $config) {
+            if (empty($config['id'])) {
+                continue;
+            }
+
+            $methods[] = [
+                'paymentMethod' => 'knit_pay_' . $config['id'],
+            ];
+        }
+
+        return $methods;
+    }
+
+    /**
      * Filter Redirect URL
      *
      * Intercepts the Knit Pay redirect URL to ensure it hits the KiviCare REST API endpoint.
@@ -459,4 +484,4 @@
     {
         return $this->create_payment_response('success', 'Webhooks for Knit Pay are handled by the Pronamic plugin.', []);
     }
-}
 No newline at end of file
+}
--- a/kivicare-clinic-management-system/app/paymentGateways/KCPayLater.php
+++ b/kivicare-clinic-management-system/app/paymentGateways/KCPayLater.php
@@ -79,7 +79,7 @@
                 'Appointment booked successfully. Payment due later (manual).'
             );

-        } catch (Exception $e) {
+        } catch (Exception $e) {
             $this->log("Pay Later processing error: " . $e->getMessage(), 'error');
             return $this->create_payment_response(
                 'failed',
@@ -128,9 +128,19 @@
      */
     public function get_settings() {
         $value = $this->settings ?? 'off';
-        $this->settings = [];
-        $this->settings['enablePayLater'] = ($value === 'on');
-        return $this->settings;
+        return [
+            'enablePayLater' => ($value === 'on'),
+        ];
+    }
+
+    /**
+     * Public/mobile-safe payment config.
+     */
+    public function get_public_config(): array
+    {
+        return [
+            'paymentMethod' => 'offline',
+        ];
     }

     /**
@@ -149,4 +159,4 @@

         return $this->get_settings();
     }
-}
 No newline at end of file
+}
--- a/kivicare-clinic-management-system/app/paymentGateways/KCPaypal.php
+++ b/kivicare-clinic-management-system/app/paymentGateways/KCPaypal.php
@@ -666,6 +666,24 @@
         return $settings;
     }

+    /**
+     * Public/mobile-safe PayPal configuration.
+     */
+    public function get_public_config(): array
+    {
+        $settings = $this->get_settings();
+        $environment = ((string) ($settings['mode'] ?? '0') === '0') ? 'test' : 'live';
+
+        return [
+            'paymentMethod' => 'paypal',
+            'environment' => $environment,
+            'publicKey' => $settings['client_id'] ?? '',
+            'paymentURL' => $environment === 'test'
+                ? 'https://api.sandbox.paypal.com'
+                : 'https://api.paypal.com',
+        ];
+    }
+
     public function update_settings($settings)
     {
         $processed_settings = $this->settings;
@@ -759,4 +777,4 @@
         return $this->get_settings();
     }

-}
 No newline at end of file
+}
--- a/kivicare-clinic-management-system/app/paymentGateways/KCWooCommerce.php
+++ b/kivicare-clinic-management-system/app/paymentGateways/KCWooCommerce.php
@@ -668,6 +668,16 @@
         return $this->settings;
     }

+    /**
+     * Public/mobile-safe WooCommerce configuration.
+     */
+    public function get_public_config(): array
+    {
+        return [
+            'paymentMethod' => 'wooCommerce',
+        ];
+    }
+
     public function update_settings($settings)
     {
         $value = $settings['enableWooCommerce'] ?? 'off';
--- a/kivicare-clinic-management-system/kivicare-clinic-management-system.php
+++ b/kivicare-clinic-management-system/kivicare-clinic-management-system.php
@@ -3,7 +3,7 @@
  * Plugin Name: KiviCare - Clinic & Patient Management System (EHR)
  * Plugin URI: https://kivicare.io
  * Description: KiviCare is an impressive clinic and patient management plugin (EHR). It comes with powerful shortcodes for appointment booking and patient registration.
- * Version: 4.5.1
+ * Version: 4.5.2
  * Author: iqonic design
  * Text Domain: kivicare-clinic-management-system
  * Domain Path: /languages
@@ -46,7 +46,7 @@
 }

 if (!defined('KIVI_CARE_VERSION')) {
-	define('KIVI_CARE_VERSION', "4.5.1");
+	define('KIVI_CARE_VERSION', "4.5.2");
 }

 if (!defined('KIVI_CARE_API_VERSION')) {

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.