Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/simply-schedule-appointments/includes/class-appointment-model.php
+++ b/simply-schedule-appointments/includes/class-appointment-model.php
@@ -380,6 +380,64 @@
}
/**
+ * Mint a server-signed proof-of-browser token, bound to one appointment
+ * type. Returned alongside the availability slots so only a client that
+ * actually ran the booking flow can obtain one — a script that harvested
+ * the site-wide public nonce cannot forge it. Stateless: the token carries
+ * no expiry and no server-side use counter, so a cached availability
+ * response shared across many legitimate visitors never gets rejected.
+ * Verified on create by verify_booking_token().
+ *
+ * @param int $appointment_type_id
+ * @return string
+ */
+ public function mint_booking_token( $appointment_type_id ) {
+ $atid = (int) $appointment_type_id;
+ $rand = wp_generate_password( 16, false );
+ $payload = $atid . '.' . $rand;
+ $sig = hash_hmac( 'sha256', $payload, wp_salt( 'nonce' ) );
+
+ return base64_encode( $payload . '.' . $sig );
+ }
+
+ /**
+ * Verify a token from mint_booking_token(): valid signature, bound to the
+ * same appointment type.
+ *
+ * @param string $token
+ * @param int $appointment_type_id
+ * @return bool
+ */
+ public function verify_booking_token( $token, $appointment_type_id ) {
+ if ( ! is_string( $token ) || '' === $token ) {
+ return false;
+ }
+
+ $decoded = base64_decode( $token, true );
+ if ( false === $decoded ) {
+ return false;
+ }
+
+ $parts = explode( '.', $decoded );
+ if ( 3 !== count( $parts ) ) {
+ return false;
+ }
+
+ list( $atid, $rand, $sig ) = $parts;
+
+ $expected = hash_hmac( 'sha256', $atid . '.' . $rand, wp_salt( 'nonce' ) );
+ if ( ! hash_equals( $expected, (string) $sig ) ) {
+ return false;
+ }
+
+ if ( (int) $atid !== (int) $appointment_type_id ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
* Fields an unprivileged caller is allowed to submit when updating an
* appointment. Matches the booking app's client-side `bookingProps`
* allowlist plus the request-routing params used by update_item.
@@ -1124,6 +1182,78 @@
);
}
+ // Proof-of-browser: anonymous bookings must carry a server-minted token
+ // (issued with the availability slots) and an empty honeypot. This stops
+ // a script that harvested the site-wide public nonce from POSTing
+ // straight to this endpoint — it must run the real availability->book
+ // flow to obtain a valid, type-bound token. Logged-in users
+ // are exempt: they authenticated against WordPress and carry a real
+ // per-user nonce, so this anti-bot gate adds risk without benefit for
+ // them. Privileged (admin) bookings are exempt; the filter is an escape
+ // hatch for sites with legitimate server-to-server integrations.
+ //
+ // Opt-in: off by default so the ~70k existing installs are unaffected.
+ // Sites enable it under Settings -> Developer (require_proof_token). The
+ // filter default tracks that setting so the code hook and UI agree.
+ $developer_settings = $this->plugin->developer_settings->get();
+ $require_token = ! empty( $developer_settings['require_proof_token'] );
+ $booking_token = $request->get_header( 'x_ssa_booking_token' );
+ if ( ! is_user_logged_in() && ! $this->is_privileged_appointment_request() && apply_filters( 'ssa/booking/require_proof_token', $require_token, $params ) ) {
+ $honeypot = $request->get_param( 'ssa_hp' );
+ $token_ok = ! empty( $booking_token )
+ && $this->verify_booking_token( $booking_token, $params['appointment_type_id'] );
+
+ if ( ! empty( $honeypot ) || ! $token_ok ) {
+ return array(
+ 'error' => array(
+ 'code' => 'booking_token_invalid',
+ 'message' => __( 'There was a problem booking your appointment. Please reload the page and try again.', 'simply-schedule-appointments' ),
+ 'data' => array(),
+ ),
+ );
+ }
+ }
+
+ // Visitors must satisfy the appointment type's own required fields. The
+ // front-end enforces these, but a direct REST POST bypasses that — mirror
+ // the rule server-side. Admin/privileged bookings are exempt, and so are
+ // reserved-status creates (pending_form / pending_payment): form and
+ // payment integrations create the appointment first and collect/validate
+ // the customer fields in a later step, so they are legitimately absent
+ // at create time.
+ $incoming_status = isset( $params['status'] ) ? $params['status'] : '';
+ if ( ! $this->is_privileged_appointment_request() && ! self::is_a_reserved_status( $incoming_status ) ) {
+ $submitted = ( isset( $params['customer_information'] ) && is_array( $params['customer_information'] ) ) ? $params['customer_information'] : array();
+ $missing = array();
+
+ // custom_customer_information can be a string on Basic — guard is_array.
+ if ( is_array( $appointment_type->custom_customer_information ) ) {
+ foreach ( $appointment_type->custom_customer_information as $field ) {
+ if ( empty( $field['display'] ) || empty( $field['required'] ) || empty( $field['field'] ) ) {
+ continue;
+ }
+ $label = $field['field'];
+ $value = isset( $submitted[ $label ] ) ? $submitted[ $label ] : '';
+ if ( is_array( $value ) ) {
+ $value = implode( '', $value );
+ }
+ if ( '' === trim( (string) $value ) ) {
+ $missing[] = $label;
+ }
+ }
+ }
+
+ if ( ! empty( $missing ) ) {
+ return array(
+ 'error' => array(
+ 'code' => 'required_fields_missing',
+ 'message' => __( 'Please complete all required fields before booking.', 'simply-schedule-appointments' ),
+ 'data' => array( 'fields' => $missing ),
+ ),
+ );
+ }
+ }
+
if ( ! empty( $params['customer_information']['Email'] ) ) {
$user_by_email = get_user_by( 'email', sanitize_text_field( $params['customer_information']['Email'] ) );
if ( ! empty( $user_by_email ) ) {
@@ -1666,6 +1796,17 @@
unset( $params['format'] );
}
+ // The admin app marks its date-range view requests with admin_date_range;
+ // that view used to send number=-1 (no LIMIT), which hydrates every
+ // appointment in the range and can exhaust PHP memory on high-volume
+ // sites. Cap only requests carrying the marker.
+ if ( ! empty( $params['admin_date_range'] ) ) {
+ unset( $params['admin_date_range'] );
+ if ( empty( $params['number'] ) || (int) $params['number'] < 1 || (int) $params['number'] > 200 ) {
+ $params['number'] = 200;
+ }
+ }
+
$data = $this->query( $params );
// If complete_group is set, fetch additional appointments to complete any partial groups
@@ -2097,12 +2238,17 @@
$meta_keys_and_values = array();
$excluded_keys = array( 'id', 'context' );
+ $url_meta_keys = array( 'booking_url', 'formidable_entry_admin_url', 'gravity_entry_admin_url' );
foreach ( $metas as $key => $value ) {
if ( in_array( $key, $excluded_keys ) ) {
continue;
}
- $meta_keys_and_values[ $key ] = esc_attr( trim( $value ) );
+ if ( in_array( $key, $url_meta_keys, true ) ) {
+ $meta_keys_and_values[ $key ] = esc_url_raw( trim( $value ) );
+ } else {
+ $meta_keys_and_values[ $key ] = esc_attr( trim( $value ) );
+ }
}
$this->plugin->{$this->slug.'_meta_model'}->bulk_meta_update( $appointment_id, $meta_keys_and_values );
--- a/simply-schedule-appointments/includes/class-appointment-type-model.php
+++ b/simply-schedule-appointments/includes/class-appointment-type-model.php
@@ -1330,6 +1330,10 @@
'error' => '',
'message' => apply_filters('ssa/appointment_type/availability/display_message', '' ),
'data' => $bookable_start_datetime_strings,
+ // Proof-of-browser token for the booking flow. Stateless (no expiry,
+ // no single-use), so it stays valid even when the availability
+ // response is served from cache to many visitors. Verified on create.
+ 'booking_token' => $this->plugin->appointment_model->mint_booking_token( $appointment_type_id ),
);
return $response;
--- a/simply-schedule-appointments/includes/class-developer-settings.php
+++ b/simply-schedule-appointments/includes/class-developer-settings.php
@@ -55,8 +55,8 @@
// Caution: When schema modified, please modify usage in ssa_uninstall function in simply-schedule-appointments.php
$this->schema = array(
// YYYY-MM-DD
- 'version' => '2024-04-09',
- 'fields' => array(
+ 'version' => '2026-06-25',
+ 'fields' => array(
'enabled' => array(
'name' => 'enabled',
'default_value' => true,
@@ -67,6 +67,11 @@
'default_value' => false,
),
+ 'require_proof_token' => array(
+ 'name' => 'require_proof_token',
+ 'default_value' => false,
+ ),
+
'separate_appointment_type_availability' => array(
'name' => 'separate_appointment_type_availability',
'default_value' => apply_filters( 'ssa/get_booked_periods/should_separate_availability_for_appointment_types', false ),
--- 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.10';
+ const VERSION = '1.6.12.11';
/**
* Minimum Elementor Version
@@ -29,7 +29,7 @@
*
* @var string Minimum Elementor version required to run the plugin.
*/
- const MINIMUM_ELEMENTOR_VERSION = '1.6.12.10';
+ const MINIMUM_ELEMENTOR_VERSION = '1.6.12.11';
/**
* Minimum PHP Version
@@ -38,7 +38,7 @@
*
* @var string Minimum PHP version required to run the plugin.
*/
- const MINIMUM_PHP_VERSION = '1.6.12.10';
+ const MINIMUM_PHP_VERSION = '1.6.12.11';
/**
* Instance
--- 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.10
+ * @version 1.6.12.11
* @license http://opensource.org/licenses/gpl-3.0.html
*/
--- a/simply-schedule-appointments/includes/class-rest-middleware.php
+++ b/simply-schedule-appointments/includes/class-rest-middleware.php
@@ -30,7 +30,14 @@
}
/**
- * Strip internal-only parameters from incoming SSA REST requests.
+ * Strip internal-only parameters from every incoming REST request.
+ *
+ * The strip runs for all routes, not just SSA-namespaced ones. Gating it on
+ * the request route is unsafe: WP matches routes case-insensitively, so a
+ * route filter like '/ssa/' is bypassed by requesting '/SSA/...', which lets
+ * a forbidden key (e.g. append_where_sql) survive into a controller and be
+ * concatenated into SQL. These keys are never legitimate on any HTTP request,
+ * so stripping unconditionally makes that bypass class unreachable.
*
* WP_REST_Request::offsetUnset removes the key from every parameter source
* (URL, GET, POST, JSON body, form-urlencoded body, defaults), so by the
@@ -46,10 +53,6 @@
if ( ! ( $request instanceof WP_REST_Request ) ) {
return $result;
}
-
- if ( strpos( $request->get_route(), '/ssa/' ) !== 0 ) {
- return $result;
- }
foreach ( self::FORBIDDEN_REQUEST_PARAMS as $forbidden ) {
unset( $request[ $forbidden ] );
--- a/simply-schedule-appointments/includes/class-shortcodes.php
+++ b/simply-schedule-appointments/includes/class-shortcodes.php
@@ -762,6 +762,34 @@
'ssa_admin_upcoming_appointments'
);
+ // Non-managers are pinned to their own staff scope, recomputed from
+ // capability rather than trusted from $atts. Two traps make the atts
+ // unsafe as a filter:
+ // (1) shortcode atts are always strings, and the staff WHERE clause is
+ // only appended when staff_ids_any is an array (see
+ // SSA_Staff_Appointment_Model::filter_query_by_staff_ids) -- a passed
+ // value silently drops the filter and the query returns every row;
+ // (2) staff_appointment_model registers that WHERE-clause filter in its
+ // hooks(), and its file is stripped from Pro/Plus/Basic, so on those
+ // editions the filter never runs at all.
+ // When the query cannot be scoped (no staff record, or the model is
+ // absent) return nothing rather than leak the whole appointment book.
+ if ( ! current_user_can( 'ssa_manage_others_appointments' ) ) {
+ $staff_id = $this->plugin->staff_model->get_staff_id_for_user_id( get_current_user_id() );
+
+ if ( ! current_user_can( 'ssa_manage_appointments' )
+ || empty( $staff_id )
+ || $this->plugin->staff_appointment_model instanceof SSA_Missing ) {
+ return '';
+ }
+
+ // customer_id is honored as passed rather than cleared: it is only ever ANDed
+ // into the query (SSA_Appointment_Model::filter_where_conditions), so alongside
+ // the staff clause below it can narrow this staff member's own appointments but
+ // never reach outside them.
+ $atts['staff_ids_any'] = array( (int) $staff_id );
+ }
+
ob_start();
include $this->plugin->dir( 'templates/dashboard/dashboard-upcoming-appointments-widget.php' );
$output = ob_get_clean();
@@ -793,7 +821,15 @@
$atts,
'ssa_upcoming_appointments'
);
-
+
+ // IDOR guard: customer_id is an overridable shortcode att. Only a user
+ // allowed to manage others' appointments may read another customer's
+ // data; everyone else is pinned to their own ID regardless of the
+ // supplied value.
+ if ( ! current_user_can( 'ssa_manage_others_appointments' ) ) {
+ $atts['customer_id'] = get_current_user_id();
+ }
+
ob_start();
include $this->plugin->dir( 'templates/customer/past-appointments.php' );
$output = ob_get_clean();
@@ -846,6 +882,16 @@
'ssa_upcoming_appointments'
);
+ // IDOR guard: customer_id and customer_information (email) are both
+ // overridable atts, and appointment_model->query() ORs them together, so
+ // either lever could read another customer's appointments. Only a user
+ // allowed to manage others' appointments may do so; everyone else is
+ // pinned to their own ID and email.
+ if ( ! current_user_can( 'ssa_manage_others_appointments' ) ) {
+ $atts['customer_id'] = get_current_user_id();
+ $atts['customer_information'] = wp_get_current_user()->user_email;
+ }
+
ob_start();
include $this->plugin->dir( 'templates/customer/upcoming-appointments.php' );
$output = ob_get_clean();
--- a/simply-schedule-appointments/includes/class-wp-admin.php
+++ b/simply-schedule-appointments/includes/class-wp-admin.php
@@ -54,6 +54,7 @@
add_action( 'admin_print_scripts', array( $this, 'remove_admin_notices' ) );
add_filter( 'plugin_action_links', array( $this, 'plugin_action_upgrade_link'), 10, 2 );
+ add_action( 'admin_print_styles-plugins.php', array( $this, 'plugin_action_upgrade_link_styles' ) );
// rest api
add_action( 'rest_api_init', array( $this, 'register_wp_endpoints' ) );
@@ -237,6 +238,18 @@
$ssa_pricing_url = 'https://simplyscheduleappointments.com/pricing/?utm_source=plugin&utm_medium=ads&utm_campaign=upgrade&utm_content=upgrade-plugin-listing';
+ $upgrade_link = '<a class="ssa-basic-upgrade-link-admin-dash" target="_blank" href="' . $ssa_pricing_url . '">' . __('Upgrade', 'simply-schedule-appointments') . '</a>';
+
+ $links['upgrade'] = $upgrade_link;
+ return $links;
+
+ }
+
+ public function plugin_action_upgrade_link_styles() {
+ if ( $this->plugin->get_current_edition() !== 1 ) {
+ return;
+ }
+
echo "<style id='ssa-admin-dash-upgrade-link-css'>
.ssa-basic-upgrade-link-admin-dash {
padding: 2px 4px;
@@ -245,18 +258,12 @@
color: #fff;
border: none;
}
-
+
.ssa-basic-upgrade-link-admin-dash:hover {
background-color: #046a66;
color: white;
}
</style>";
-
- $upgrade_link = '<a class="ssa-basic-upgrade-link-admin-dash" target="_blank" href="' . $ssa_pricing_url . '">' . __('Upgrade', 'simply-schedule-appointments') . '</a>';
-
- $links['upgrade'] = $upgrade_link;
- return $links;
-
}
public function register_admin_menu() {
--- a/simply-schedule-appointments/languages/admin-app-translations.php
+++ b/simply-schedule-appointments/languages/admin-app-translations.php
@@ -529,6 +529,11 @@
'label' => __( 'Enqueue scripts everywhere', 'simply-schedule-appointments' ),
'help' => __( 'Load scripts on every page. Can be helpful if there are issues with loading the booking form via ajax', 'simply-schedule-appointments' ),
),
+ 'require_proof_token' =>
+ array (
+ 'label' => __( 'Reduce spam bookings', 'simply-schedule-appointments' ),
+ 'help' => __( 'Helps block automated spam by checking that bookings come from the booking form. Turn this on if you're getting spam bookings.', 'simply-schedule-appointments' ),
+ ),
'beta_updates' =>
array (
'title' => __( 'Beta features', 'simply-schedule-appointments' ),
--- 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.10
+ * Version: 1.6.12.11
* 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.10
+ * @version 1.6.12.11
*
* 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.10';
+ const VERSION = '1.6.12.11';
/**
* 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' => 'c347122ca311384c8fee9637d7565e25f2bc6011',
+ 'reference' => '89e0c871dbfd4cdf7054697cc9c69423aa9cbe8e',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -13,7 +13,7 @@
'__root__' => array(
'pretty_version' => 'dev-master',
'version' => 'dev-master',
- 'reference' => 'c347122ca311384c8fee9637d7565e25f2bc6011',
+ 'reference' => '89e0c871dbfd4cdf7054697cc9c69423aa9cbe8e',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),