The Exploit
An unauthenticated attacker can inject a stored XSS payload into a Rich-Text Textarea field; when a site administrator opens the submission in the Forminator Entries view and clicks a crafted link embedded in the stored data, the payload executes in the administrator's session.
Step 1: Store the XSS payload via form submission
POST /index.php?action=forminator_submit_form HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
form_id=1&forminator_uid=abc123&forminator_timestamp=1234567890&forminator_nonce=nonce_value&fields[rich_textarea_field]=<a href="javascript:alert('XSS')">click me</a>&submit=Submit
The attacker URL-encodes a hyperlink with an entity-encoded javascript: protocol in the href attribute. The form stores this in the database as a Rich-Text Textarea entry. No authentication is required; the form accepts public submissions.
Step 2: Trigger the payload
An administrator logs into wp-admin, navigates to Forminator → Entries, and opens the stored submission containing the injected link. When the admin clicks the link—either intentionally or via social engineering—the entity-encoded href is decoded by the browser's HTML parser and by WordPress jQuery-based click handlers, executing the JavaScript in the admin's authenticated context.
The attacker observes the alert box fire in the administrator's browser, confirming code execution. From there, the attacker can steal session cookies, create backdoor admin accounts, or exfiltrate sensitive form data.
What the Patch Did
Before
function forminator_get_rich_textarea_allowed_html() {
$allowed_html = wp_kses_allowed_html( 'post' );
foreach ( $allowed_html as $tag => $attributes ) {
if ( ! is_array( $attributes ) ) {
continue;
}
if ( isset( $allowed_html[ $tag ]['class'] ) ) {
$allowed_html[ $tag ]['class'] = array(
'value_callback' => 'forminator_is_allowed_rich_textarea_class_attribute',
);
}
}
return $allowed_html;
}
After
function forminator_get_rich_textarea_allowed_html() {
$allowed_html = wp_kses_allowed_html( 'post' );
// Validate URL attributes so entity-encoded markup cannot ride in them.
$url_attributes = wp_kses_uri_attributes();
foreach ( $allowed_html as $tag => $attributes ) {
if ( ! is_array( $attributes ) ) {
continue;
}
if ( isset( $allowed_html[ $tag ]['class'] ) ) {
$allowed_html[ $tag ]['class'] = array(
'value_callback' => 'forminator_is_allowed_rich_textarea_class_attribute',
);
}
foreach ( $url_attributes as $url_attribute ) {
if ( isset( $allowed_html[ $tag ][ $url_attribute ] ) ) {
$allowed_html[ $tag ][ $url_attribute ] = array(
'value_callback' => 'forminator_is_allowed_rich_textarea_url_attribute',
);
}
}
}
return $allowed_html;
}
function forminator_is_allowed_rich_textarea_url_attribute( $url_value ) {
$decoded = (string) $url_value;
do {
$previous = $decoded;
$decoded = html_entity_decode( $previous, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
} while ( $decoded !== $previous );
if ( preg_match( '/[<>"\'`]/', $decoded ) ) {
return false;
}
return true;
}
The patch introduces a new validation callback function forminator_is_allowed_rich_textarea_url_attribute() and applies it via wp_kses value callbacks to all URL-bearing attributes (href, src, data, etc.) across all HTML tags. The callback decodes entity-encoded characters iteratively (to catch multi-encoded payloads) and rejects any value containing unencoded angle brackets, quotes, or backticks—characters that can break out of HTML and JavaScript sinks.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
User-supplied content from public form submissions flows into the Rich-Text Textarea field. The plugin's sanitization pipeline uses wp_kses() to allow safe HTML tags and attributes—but wp_kses() preserves HTML entities verbatim. An attacker can entity-encode a malicious protocol (javascript: = javascript:), and it will pass wp_kses() validation. When the stored data is later rendered in the WordPress admin Entries view, client-side code (jQuery, the browser's HTML parser, or inline event handlers) decodes the entities back into a live javascript: link, crossing the trust boundary from a sanitized stored string into executable code.
Why It Works
The load-bearing line is the regex match in forminator_is_allowed_rich_textarea_url_attribute():
if ( preg_match( '/[<>"\'`]/', $decoded ) ) {
return false;
}
Without this check, entity-encoded dangerous characters would still pass validation. The surrounding while loop that decodes iteratively is equally critical—it ensures that multi-encoded payloads (e.g., &#106; = j = j when double-decoded) cannot bypass the check by encoding the encoded characters themselves. If you removed just the regex line, an attacker could still inject javascript: and execute it. The iteration loop prevents a second encoding layer from slipping through. Together, they form a gating function: only URL attribute values that decode to safe, literal text are allowed; anything decoding to HTML syntax is rejected before storage, not just before display.
Hardening Checklist
- Use
wp_kses()with explicitvalue_callbackvalidators for all user-controlled URL-bearing attributes (not justclass). Do not rely onwp_kses()alone to reject entity-encoded attack vectors; it preserves entities by design. - Implement iterative entity decoding in validators (
html_entity_decode()in a loop until the string stabilizes) to catch multi-encoded payloads that single-pass decoding would miss. - Test form storage endpoints with entity-encoded XSS payloads (e.g.,
<script>) and verify they are either rejected at input validation or stripped from the allowlist before the entry is saved. - Do not perform
wp_unslash()at each recursion level when recursively sanitizing nested arrays; unslash once at the entry point, then callwp_kses()before storage to avoid encoding-layer bypasses. - Audit all rich-text and WYSIWYG fields for similar whitelist gaps; apply URL attribute validation consistently across the codebase.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-85235