Fluent Booking <= 2.0.01 - Unauthenticated Stored Cross-Site Scripting via Multiple Parameters
Description
The Fluent Booking plugin for WordPress is vulnerable to Stored Cross-Site Scripting via multiple parameters in all versions up to, and including, 2.0.01 due to insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:NTechnical Details
<=2.0.01What Changed in the Fix
Changes introduced in v2.0.05
Source Code
WordPress.org SVNThis research plan targets **CVE-2026-2231**, an unauthenticated stored Cross-Site Scripting (XSS) vulnerability in the Fluent Booking plugin. ### 1. Vulnerability Summary The Fluent Booking plugin (up to version 2.0.01) fails to sufficiently sanitize and escape attendee-provided data during the bo…
Show full research plan
This research plan targets CVE-2026-2231, an unauthenticated stored Cross-Site Scripting (XSS) vulnerability in the Fluent Booking plugin.
1. Vulnerability Summary
The Fluent Booking plugin (up to version 2.0.01) fails to sufficiently sanitize and escape attendee-provided data during the booking process. Specifically, when an unauthenticated user schedules a meeting via the wp_ajax_nopriv_fluent_cal_schedule_meeting AJAX action, several input parameters (like name, message, and custom fields) are stored in the database. While some parameters undergo basic sanitization (e.g., sanitize_text_field), the data is later rendered in the admin "Schedules" dashboard and potentially in export files (CSV/JSON) without proper output escaping. This allows an attacker to inject arbitrary scripts that execute in the context of an administrative user.
2. Attack Vector Analysis
- Endpoint:
/wp-admin/admin-ajax.php - Action:
fluent_cal_schedule_meeting(specifically thenoprivversion) - Vulnerable Parameters:
name,message,address,phone_number,location_description, andcustom_fields. - Authentication: Unauthenticated (no account required).
- Preconditions: A Calendar and at least one active Calendar Slot (Event) must exist.
3. Code Flow
- Entry Point:
FrontEndHandler::register()defineswp_ajax_nopriv_fluent_cal_schedule_meetingwhich routes toajaxScheduleMeeting. - Processing:
BookingController::createBooking()is invoked. - Data Extraction: The controller gathers input via
$request->all(). - Sanitization (Insufficient):
nameis passed throughsanitize_text_field().message,phone, andaddressare passed throughsanitize_textarea_field().custom_fieldsare processed byBookingFieldService::getCustomFieldsData().
- Persistence: Data is saved to the
fcal_bookingstable via theBookingmodel. - Sink (Rendering):
- Admin dashboard:
SchedulesController::index()returns booking data in JSON format. The React/Vue frontend renders these values (e.g.,message) potentially using unsafe methods likev-htmlor simply failing to escape within the DOM. - Data Exports:
DataExporter::exportBookingHosts()writes raw model values to a CSV.
- Admin dashboard:
4. Nonce Acquisition Strategy
The booking form requires a nonce for the action fluent_cal_schedule_meeting. This nonce is localized into the page where the booking shortcode is present.
- Identify Shortcode: The plugin uses
[fluent_booking id="XX"]or[fluent_booking hash="YY"]. - Setup Page: Create a page containing a valid booking shortcode.
- Navigate: Use the browser tool to visit this page.
- Extract Nonce: The plugin localizes data into a global JavaScript variable named
fcal_public_vars_{calendar_id}_{event_id}. - JS Command:
Note: Based on// Example for Calendar 1, Event 1 window.fcal_public_vars_1_1?.nonce || window.fcal_public_vars_1_1?._fcal_tokenFrontEndHandler.php, the localized variable name is strictlyfcal_public_vars_+ ID +_+ EventID.
5. Exploitation Strategy
- Discovery:
- Find an active
event_idand its correspondingcalendar_id. - Find an available time slot for that event by calling the
nopriv_fluent_cal_get_available_datesaction ornopriv_fluent_cal_get_available_slots.
- Find an active
- Payload Preparation: Use a standard XSS payload that survives
sanitize_text_field(which strips tags but can be bypassed if the sink is an attribute or if the sanitization is skipped for specific fields).- Primary:
<img src=x onerror=alert(document.domain)> - Attribute Breakout:
"><svg/onload=alert(1)>
- Primary:
- Submission:
- Send a POST request to
admin-ajax.php. - Action:
fluent_cal_schedule_meeting - Required Fields:
name,email,timezone,event_time,_fcal_token. - Payload Fields: Inject into
name,message, andaddress.
- Send a POST request to
6. Test Data Setup
- Create Calendar: Use WP-CLI to ensure at least one calendar exists.
wp eval "FluentBooking\App\Models\Calendar::create(['title' => 'Test Calendar', 'user_id' => 1, 'type' => 'simple', 'status' => 'active']);" - Create Event (Slot):
wp eval "FluentBooking\App\Models\CalendarSlot::create(['calendar_id' => 1, 'title' => 'Consultation', 'slug' => 'consult', 'status' => 'active', 'duration' => 30, 'event_type' => 'one_on_one']);" - Create Public Page:
wp post create --post_type=page --post_title="Book Here" --post_content='[fluent_booking id="1"]' --post_status=publish
7. Expected Results
- The
ajaxScheduleMeetingrequest should return200 OKor201 Createdwith a success message. - When an administrator navigates to Fluent Booking > Schedules, the injected script in the
nameormessagefield should execute immediately.
8. Verification Steps
- Check Database:
wp db query "SELECT name, message FROM wp_fcal_bookings ORDER BY id DESC LIMIT 1;" - Verify Admin Execution: Use
browser_navigatetohttp://localhost:8080/wp-admin/admin.php?page=fluent-booking#/schedulesand check for an alert or a specific DOM change.
9. Alternative Approaches
- Custom Fields: If the main fields are sanitized, target
custom_fields. In version 2.0.01, the custom field processing inBookingFieldServicemay not strictly sanitize all input types.- Parameter:
custom_fields[1]=<script>alert(1)</script>
- Parameter:
- UTM Parameters: The plugin captures UTM data (
utm_source,utm_medium). These are often overlooked and rendered in the "Attendee Info" section of the schedule.- Parameter:
utm_source=<img src=x onerror=alert(1)>
- Parameter:
- Export Injection: If the XSS doesn't trigger in the dashboard, check the CSV export functionality in
DataExporter::exportBookingHosts. Inject=cmd|' /C calc'!A1to test for CSV injection as well.
Summary
The Fluent Booking plugin for WordPress is vulnerable to unauthenticated stored Cross-Site Scripting (XSS) due to insufficient sanitization and escaping of attendee-provided data such as names, messages, and UTM parameters. Attackers can inject arbitrary scripts during the booking process that execute when an administrator views the scheduling dashboard or exports booking data.
Vulnerable Code
// app/Hooks/Handlers/FrontEndHandler.php:840 $bookingData = apply_filters('fluent_booking/initialize_booking_data', [ 'person_time_zone' => sanitize_text_field($timezone), 'start_time' => $startDateTime, 'name' => sanitize_text_field($postedData['name']), 'email' => sanitize_email($postedData['email']), 'message' => sanitize_textarea_field(wp_unslash(Arr::get($postedData, 'message', ''))), 'phone' => sanitize_textarea_field(Arr::get($postedData, 'phone_number', '')), 'address' => sanitize_textarea_field(Arr::get($postedData, 'address', '')), 'ip_address' => Helper::getIp(), 'status' => 'scheduled', 'source' => 'web', 'event_type' => $calendarEvent->event_type, 'slot_minutes' => $duration, 'utm_source' => SanitizeService::sanitizeUtmData(Arr::get($postedData, 'utm_source', '')), 'utm_medium' => SanitizeService::sanitizeUtmData(Arr::get($postedData, 'utm_medium', '')), 'utm_campaign' => SanitizeService::sanitizeUtmData(Arr::get($postedData, 'utm_campaign', '')), 'utm_term' => SanitizeService::sanitizeUtmData(Arr::get($postedData, 'utm_term', '')), 'utm_content' => SanitizeService::sanitizeUtmData(Arr::get($postedData, 'utm_content', '')) ], $postedData, $calendarEvent); --- // app/Hooks/Handlers/DataExporter.php:83 foreach ($attendees as $attendee) { $row = [ $attendee->first_name, $attendee->last_name, $attendee->email, $attendee->message, $attendee->getLocationAsText(), $attendee->source, $attendee->booking_type, $attendee->status, $attendee->source_url, $attendee->slot_minutes, $attendee->start_time, $attendee->end_time, $attendee->payment_status, ];
Security Fix
@@ -80,36 +88,36 @@ foreach ($attendees as $attendee) { $row = [ - $attendee->first_name, - $attendee->last_name, - $attendee->email, - $attendee->message, - $attendee->getLocationAsText(), - $attendee->source, - $attendee->booking_type, - $attendee->status, - $attendee->source_url, - $attendee->slot_minutes, - $attendee->start_time, - $attendee->end_time, - $attendee->payment_status, + $this->sanitizeCsvCell($attendee->first_name), + $this->sanitizeCsvCell($attendee->last_name), + $this->sanitizeCsvCell($attendee->email), + $this->sanitizeCsvCell($attendee->message), + $this->sanitizeCsvCell($attendee->getLocationAsText()), + $this->sanitizeCsvCell($attendee->source), + $this->sanitizeCsvCell($attendee->booking_type), + $this->sanitizeCsvCell($attendee->status), + $this->sanitizeCsvCell($attendee->source_url), + $this->sanitizeCsvCell($attendee->slot_minutes), + $this->sanitizeCsvCell($attendee->start_time), + $this->sanitizeCsvCell($attendee->end_time), + $this->sanitizeCsvCell($attendee->payment_status), ]; if ($attendee->payment_status) { $order = $attendee->payment_order; if ($order) { $order->load(['items', 'transaction']); - $row[] = $order->status; - $row[] = $order->payment_method; - $row[] = $order->currency; + $row[] = $this->sanitizeCsvCell($order->status); + $row[] = $this->sanitizeCsvCell($order->payment_method); + $row[] = $this->sanitizeCsvCell($order->currency); $row[] = $order->total_amount / 100; - $row[] = $order->created_at; - $row[] = $order->transaction->id; - $row[] = $order->transaction->vendor_charge_id; - $row[] = $order->transaction->payment_method; - $row[] = $order->transaction->status; + $row[] = $this->sanitizeCsvCell($order->created_at); + $row[] = $this->sanitizeCsvCell($order->transaction->id); + $row[] = $this->sanitizeCsvCell($order->transaction->vendor_charge_id); + $row[] = $this->sanitizeCsvCell($order->transaction->payment_method); + $row[] = $this->sanitizeCsvCell($order->transaction->status); $row[] = $order->transaction->total / 100; - $row[] = $order->transaction->created_at; + $row[] = $this->sanitizeCsvCell($order->transaction->created_at); } } else {
Exploit Outline
To exploit this vulnerability, an unauthenticated attacker first obtains a valid public booking nonce (usually found in the localized JavaScript of any page containing the `[fluent_booking]` shortcode). The attacker then sends a POST request to the `wp_ajax_nopriv_fluent_cal_schedule_meeting` AJAX endpoint. The payload includes standard booking fields like `name`, `email`, and `event_time`, but injects XSS vectors (e.g., `<script>alert(1)</script>` or `<img src=x onerror=alert(1)>`) into parameters such as `message`, `utm_source`, or custom fields. Once submitted, the malicious script is stored in the database and executes in the context of an administrator's browser session when they navigate to the Fluent Booking 'Schedules' page or export the booking data.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.