The Exploit
An authenticated attacker with contributor-level access injects arbitrary JavaScript into the other_attributes parameter of a Related Posts widget configuration request, which is then stored in the database and executed in the browser of any user viewing that page.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in_<hash>=contributor_user|<auth_token>
action=crp_save_widget&widget_id=crp-1&other_attributes=class="x
The POST request returns a 200 response confirming the widget configuration was saved. When any site visitor navigates to a page displaying the Related Posts widget, their browser renders the injected onclick handler, executing the attacker's JavaScript in their session context — stealing cookies, redirecting to phishing, or performing actions on their behalf.
The payload persists in wp_options or widget metadata until manually removed by an administrator.
What the Patch Did
Before:
public static function sanitize_args( $args ): array {
foreach ( $args as $key => $value ) {
if ( is_string( $value ) ) {
$args[ $key ] = wp_kses_post( $value );
}
}
return $args;
}
After:
public static function sanitize_args( $args ): array {
foreach ( $args as $key => $value ) {
if ( is_string( $value ) ) {
switch ( $key ) {
case 'class':
case 'className':
case 'extra_class':
$classes = explode( ' ', $value );
$sanitized_classes = array_map( 'sanitize_html_class', $classes );
$args[ $key ] = implode( ' ', $sanitized_classes );
break;
default:
$args[ $key ] = wp_kses_post( $value );
break;
}
}
}
return $args;
}
The patch introduced context-aware sanitization using sanitize_html_class() for class-related parameters (class, className, extra_class). The original code applied wp_kses_post() uniformly across all string values, which strips dangerous HTML tags but does not prevent attribute injection when the value is intended as a CSS class token rather than arbitrary HTML content. The fix tokenizes class strings on whitespace, applies WordPress's built-in sanitize_html_class() to each individual class name (stripping quotes, event handlers, and non-alphanumeric characters), and rejoins them — ensuring that event handler attributes cannot be smuggled into the class value and rendered as live HTML attributes.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation The attacker-controlled other_attributes parameter flows from the AJAX request directly into sanitize_args(), which was intended to prepare widget configuration for storage and display. The original sanitizer (wp_kses_post()) was designed for sanitizing rich content (paragraphs, links, emphasized text), not HTML attribute values. When the sanitized class value is later rendered into an HTML tag's class attribute via string concatenation or template interpolation, the quotes and event handlers embedded in the attacker's payload remain intact, crossing the trust boundary from user input to executable client-side code. The vulnerability manifests because wp_kses_post() permits quotes as literal characters within its output — they are valid in text content — but when that output is placed inside an attribute value without re-escaping, those quotes allow an attacker to break out of the attribute and inject new attributes.
Why It Works
The load-bearing line is sanitize_html_class( $class ), which WordPress defines to strip all characters except alphanumerics, hyphens, and underscores. If you removed this call and reverted to wp_kses_post() on the class value, the bug remains exploitable: wp_kses_post('class="x x"') returns the string unchanged, since it contains no HTML tags to strip. The engineer added the explode() and implode() lines to handle multi-class values (e.g., "btn btn-primary") correctly; removing them would break legitimate multi-class configurations. The switch statement is the dispatch layer ensuring that only class-related keys receive context-specific handling — other keys still receive wp_kses_post(), which is appropriate for content that may legitimately contain HTML tags (like widget titles or descriptions). The combination is defense-in-depth: tokenization + per-token validation + context-aware routing prevents both the immediate XSS and future regression if someone reintroduces a bare wp_kses_post() call.
Hardening Checklist
-
Audit all uses of
wp_kses_post()on HTML attribute values. If user input is destined for an attribute (class, title, data-*, id), use attribute-specific sanitizers likesanitize_html_class(),sanitize_key(), oresc_attr()at the point of output. Do not rely onwp_kses_post()to safe-list attributes. -
Apply
sanitize_html_class()to any user-controlled CSS class list. Split on whitespace, apply the sanitizer per token, and rejoin. Treat class attributes as a restricted grammar, not arbitrary content. -
Escape at the point of output, not at the point of storage. Store the raw (or minimally sanitized) value in the database; apply context-aware escaping when rendering (e.g.,
esc_attr()for attributes,wp_kses_post()for HTML content,esc_js()for JavaScript strings). -
Write unit tests for each parameter type. Test that class values reject quotes, event handlers, and special characters, while title or description parameters still allow safe HTML. This catches regressions when code is refactored.
-
Use a static analysis tool or code review checklist to flag parameter names containing "class", "attribute", or "html". These are high-risk sinks; flag them for manual review of downstream sanitization before storage or output.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-2986